diff options
59 files changed, 1000 insertions, 427 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 570b9c98af1..f8b417bf2d6 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -f69cea16bcc88ddf29fb6c4c67a5d788fbc00f9a +780588a55b9219f3157cc984f7e1b7aa9f9124f2 diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index 33d86dec767..e9329fb1d88 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -1,7 +1,12 @@ <script> import { GlFilteredSearchToken } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { getParameterByName, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; +import { + getParameterByName, + setUrlParams, + queryToObject, + redirectTo, +} from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { SEARCH_TOKEN_TYPE, @@ -122,14 +127,16 @@ export default { const sortParamValue = getParameterByName(SORT_QUERY_PARAM_NAME); const activeTabParamValue = getParameterByName(ACTIVE_TAB_QUERY_PARAM_NAME); - window.location.href = setUrlParams( - { - ...params, - ...(sortParamValue && { [SORT_QUERY_PARAM_NAME]: sortParamValue }), - ...(activeTabParamValue && { [ACTIVE_TAB_QUERY_PARAM_NAME]: activeTabParamValue }), - }, - window.location.href, - true, + redirectTo( + setUrlParams( + { + ...params, + ...(sortParamValue && { [SORT_QUERY_PARAM_NAME]: sortParamValue }), + ...(activeTabParamValue && { [ACTIVE_TAB_QUERY_PARAM_NAME]: activeTabParamValue }), + }, + window.location.href, + true, + ), ); }, }, diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue index 89add0a0d31..1dc40f57efb 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue @@ -1,7 +1,11 @@ <script> import { GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { PackageType } from '~/packages/shared/constants'; +import { + PACKAGE_TYPE_NUGET, + PACKAGE_TYPE_CONAN, + PACKAGE_TYPE_MAVEN, +} from '~/packages_and_registries/package_registry/constants'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; export default { @@ -25,12 +29,18 @@ export default { }, computed: { showMetadata() { - const visibilityConditions = { - [PackageType.NUGET]: this.packageEntity.nuget_metadatum, - [PackageType.CONAN]: this.packageEntity.conan_metadatum, - [PackageType.MAVEN]: this.packageEntity.maven_metadatum, - }; - return visibilityConditions[this.packageEntity.package_type]; + return [PACKAGE_TYPE_NUGET, PACKAGE_TYPE_CONAN, PACKAGE_TYPE_MAVEN].includes( + this.packageEntity.packageType, + ); + }, + showNugetMetadata() { + return this.packageEntity.packageType === PACKAGE_TYPE_NUGET; + }, + showConanMetadata() { + return this.packageEntity.packageType === PACKAGE_TYPE_CONAN; + }, + showMavenMetadata() { + return this.packageEntity.packageType === PACKAGE_TYPE_MAVEN; }, }, }; @@ -41,12 +51,12 @@ export default { <h3 class="gl-font-lg" data-testid="title">{{ __('Additional Metadata') }}</h3> <div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base" data-testid="main"> - <template v-if="packageEntity.nuget_metadatum"> + <template v-if="showNugetMetadata"> <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source"> <gl-sprintf :message="$options.i18n.sourceText"> <template #link> - <gl-link :href="packageEntity.nuget_metadatum.project_url" target="_blank">{{ - packageEntity.nuget_metadatum.project_url + <gl-link :href="packageEntity.metadata.projectUrl" target="_blank">{{ + packageEntity.metadata.projectUrl }}</gl-link> </template> </gl-sprintf> @@ -54,8 +64,8 @@ export default { <details-row icon="license" padding="gl-p-4" data-testid="nuget-license"> <gl-sprintf :message="$options.i18n.licenseText"> <template #link> - <gl-link :href="packageEntity.nuget_metadatum.license_url" target="_blank">{{ - packageEntity.nuget_metadatum.license_url + <gl-link :href="packageEntity.metadata.licenseUrl" target="_blank">{{ + packageEntity.metadata.licenseUrl }}</gl-link> </template> </gl-sprintf> @@ -63,28 +73,28 @@ export default { </template> <details-row - v-else-if="packageEntity.conan_metadatum" + v-else-if="showConanMetadata" icon="information-o" padding="gl-p-4" data-testid="conan-recipe" > <gl-sprintf :message="$options.i18n.recipeText"> - <template #recipe>{{ packageEntity.name }}</template> + <template #recipe>{{ packageEntity.metadata.recipe }}</template> </gl-sprintf> </details-row> - <template v-else-if="packageEntity.maven_metadatum"> + <template v-else-if="showMavenMetadata"> <details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app"> <gl-sprintf :message="$options.i18n.appName"> <template #name> - <strong>{{ packageEntity.maven_metadatum.app_name }}</strong> + <strong>{{ packageEntity.metadata.appName }}</strong> </template> </gl-sprintf> </details-row> <details-row icon="information-o" padding="gl-p-4" data-testid="maven-group"> <gl-sprintf :message="$options.i18n.appGroup"> <template #group> - <strong>{{ packageEntity.maven_metadatum.app_group }}</strong> + <strong>{{ packageEntity.metadata.appGroup }}</strong> </template> </gl-sprintf> </details-row> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue index 903d7ae25ae..ae5558111e6 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue @@ -19,13 +19,13 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; -// import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue'; // import DependencyRow from '~/packages/details/components/dependency_row.vue'; // import InstallationCommands from '~/packages/details/components/installation_commands.vue'; // import PackageFiles from '~/packages/details/components/package_files.vue'; // import PackageListRow from '~/packages/shared/components/package_list_row.vue'; import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; import { packageTypeToTrackCategory } from '~/packages/shared/utils'; +import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import { PACKAGE_TYPE_NUGET, @@ -61,7 +61,7 @@ export default { // PackageListRow, // DependencyRow, PackageHistory, - // AdditionalMetadata, + AdditionalMetadata, // InstallationCommands, // PackageFiles, }, @@ -244,9 +244,9 @@ export default { :package-entity="packageEntity" :npm-path="npmPath" :npm-help-path="npmHelpPath" - /> + /> --> - <additional-metadata :package-entity="packageEntity" /> --> + <additional-metadata :package-entity="packageEntity" /> </div> <!-- <package-files diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json new file mode 100644 index 00000000000..c61a653d10b --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json @@ -0,0 +1,17 @@ +{ + "__schema": { + "types": [ + { + "kind": "UNION", + "name": "PackageMetadata", + "possibleTypes": [ + { "name": "ComposerMetadata" }, + { "name": "ConanMetadata" }, + { "name": "MavenMetadata" }, + { "name": "NugetMetadata" }, + { "name": "PypiMetadata" } + ] + } + ] + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js index 16152eb81f6..f8cb5c516e2 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js @@ -1,6 +1,12 @@ +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import introspectionQueryResultData from './fragmentTypes.json'; + +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); Vue.use(VueApollo); @@ -8,6 +14,9 @@ export const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( {}, { + cacheConfig: { + fragmentMatcher, + }, assumeImmutableResults: true, }, ), diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index 564476ba26a..2dff256525a 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -40,5 +40,35 @@ query getPackageDetails($id: ID!) { size } } + metadata { + ... on ComposerMetadata { + targetSha + composerJson { + license + version + } + } + ... on PypiMetadata { + requiredPython + } + ... on ConanMetadata { + packageChannel + packageUsername + recipe + recipePath + } + ... on MavenMetadata { + appName + appGroup + appVersion + path + } + + ... on NugetMetadata { + iconUrl + licenseUrl + projectUrl + } + } } } diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 9ef1e414872..683e70248b6 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -15,26 +15,22 @@ checkbox_options: { disabled: !can_change_prevent_sharing_groups_outside_hierarchy?(@group) } .form-group.gl-mb-3 - .gl-form-checkbox.custom-control.custom-checkbox - = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'custom-control-input' - = f.label :share_with_group_lock, class: 'custom-control-label' do - %span - = s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: link_to_group(@group) } - %p.js-descr.help-text= share_with_group_lock_help_text(@group) + = f.gitlab_ui_checkbox_component :share_with_group_lock, + s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: link_to_group(@group) }, + checkbox_options: { disabled: !can_change_share_with_group_lock?(@group) }, + help_text: share_with_group_lock_help_text(@group) .form-group.gl-mb-3 - .gl-form-checkbox.custom-control.custom-checkbox - = f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'custom-control-input' - = f.label :emails_disabled, class: 'custom-control-label' do - %span= s_('GroupSettings|Disable email notifications') - %p.help-text= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.') + = f.gitlab_ui_checkbox_component :emails_disabled, + s_('GroupSettings|Disable email notifications'), + checkbox_options: { checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group) }, + help_text: s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.') .form-group.gl-mb-3 - .gl-form-checkbox.custom-control.custom-checkbox - = f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'custom-control-input' - = f.label :mentions_disabled, class: 'custom-control-label' do - %span= s_('GroupSettings|Disable group mentions') - %p.help-text= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.') + = f.gitlab_ui_checkbox_component :mentions_disabled, + s_('GroupSettings|Disable group mentions'), + checkbox_options: { checked: @group.mentions_disabled? }, + help_text: s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.') = render 'groups/settings/project_access_token_creation', f: f, group: @group = render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group diff --git a/db/migrate/20210729161242_remove_foreign_keys_from_ci_test_case_failures.rb b/db/migrate/20210729161242_remove_foreign_keys_from_ci_test_case_failures.rb new file mode 100644 index 00000000000..2193a698272 --- /dev/null +++ b/db/migrate/20210729161242_remove_foreign_keys_from_ci_test_case_failures.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class RemoveForeignKeysFromCiTestCaseFailures < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + TABLE_NAME = :ci_test_case_failures + + disable_ddl_transaction! + + def up + with_lock_retries do + remove_foreign_key_if_exists(TABLE_NAME, column: :build_id) + end + + with_lock_retries do + remove_foreign_key_if_exists(TABLE_NAME, column: :test_case_id) + end + end + + def down + add_concurrent_foreign_key(TABLE_NAME, :ci_builds, column: :build_id, on_delete: :cascade) + add_concurrent_foreign_key(TABLE_NAME, :ci_test_cases, column: :test_case_id, on_delete: :cascade) + end +end diff --git a/db/migrate/20210729192148_remove_foreign_keys_from_ci_test_cases.rb b/db/migrate/20210729192148_remove_foreign_keys_from_ci_test_cases.rb new file mode 100644 index 00000000000..1d0a5f4fd64 --- /dev/null +++ b/db/migrate/20210729192148_remove_foreign_keys_from_ci_test_cases.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class RemoveForeignKeysFromCiTestCases < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + TABLE_NAME = :ci_test_cases + + disable_ddl_transaction! + + def up + with_lock_retries do + remove_foreign_key_if_exists(TABLE_NAME, column: :project_id) + end + end + + def down + add_concurrent_foreign_key(TABLE_NAME, :projects, column: :project_id, on_delete: :cascade) + end +end diff --git a/db/post_migrate/20210729192959_drop_ci_test_case_failures_table.rb b/db/post_migrate/20210729192959_drop_ci_test_case_failures_table.rb new file mode 100644 index 00000000000..ad6676a1704 --- /dev/null +++ b/db/post_migrate/20210729192959_drop_ci_test_case_failures_table.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class DropCiTestCaseFailuresTable < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + drop_table :ci_test_case_failures + end + + def down + create_table :ci_test_case_failures do |t| + t.datetime_with_timezone :failed_at + t.bigint :test_case_id, null: false + t.bigint :build_id, null: false + + t.index [:test_case_id, :failed_at, :build_id], name: 'index_test_case_failures_unique_columns', unique: true, order: { failed_at: :desc } + t.index :build_id + end + end +end diff --git a/db/post_migrate/20210729193056_drop_ci_test_cases_table.rb b/db/post_migrate/20210729193056_drop_ci_test_cases_table.rb new file mode 100644 index 00000000000..2de1749721d --- /dev/null +++ b/db/post_migrate/20210729193056_drop_ci_test_cases_table.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class DropCiTestCasesTable < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + drop_table :ci_test_cases + end + + def down + create_table_with_constraints :ci_test_cases do |t| + t.bigint :project_id, null: false + t.text :key_hash, null: false + t.text_limit :key_hash, 64 + + t.index [:project_id, :key_hash], unique: true + end + end +end diff --git a/db/schema_migrations/20210729161242 b/db/schema_migrations/20210729161242 new file mode 100644 index 00000000000..38769ac4ff0 --- /dev/null +++ b/db/schema_migrations/20210729161242 @@ -0,0 +1 @@ +22a64ce9a8cbebd2024908cc74cc92a50fb6ccaa1580ebea3be60d3659c48fa0
\ No newline at end of file diff --git a/db/schema_migrations/20210729192148 b/db/schema_migrations/20210729192148 new file mode 100644 index 00000000000..8cf650a223a --- /dev/null +++ b/db/schema_migrations/20210729192148 @@ -0,0 +1 @@ +6ed7827f6f911dbb40637ac056298877b709fb7356bc9ee3a366cceb48268646
\ No newline at end of file diff --git a/db/schema_migrations/20210729192959 b/db/schema_migrations/20210729192959 new file mode 100644 index 00000000000..df4f4ed2c71 --- /dev/null +++ b/db/schema_migrations/20210729192959 @@ -0,0 +1 @@ +3cb0c88fddfec66c0d89c4c1f34d0538be88a44f2039e6c542c5282b293ce019
\ No newline at end of file diff --git a/db/schema_migrations/20210729193056 b/db/schema_migrations/20210729193056 new file mode 100644 index 00000000000..fea83eb2750 --- /dev/null +++ b/db/schema_migrations/20210729193056 @@ -0,0 +1 @@ +d983a765482b368bd7a238b3b75fc9b0a45310f295953ea053ee4c42785e8684
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 2e31660b31e..8496f869c32 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -11340,38 +11340,6 @@ CREATE SEQUENCE ci_subscriptions_projects_id_seq ALTER SEQUENCE ci_subscriptions_projects_id_seq OWNED BY ci_subscriptions_projects.id; -CREATE TABLE ci_test_case_failures ( - id bigint NOT NULL, - failed_at timestamp with time zone, - test_case_id bigint NOT NULL, - build_id bigint NOT NULL -); - -CREATE SEQUENCE ci_test_case_failures_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE ci_test_case_failures_id_seq OWNED BY ci_test_case_failures.id; - -CREATE TABLE ci_test_cases ( - id bigint NOT NULL, - project_id bigint NOT NULL, - key_hash text NOT NULL, - CONSTRAINT check_dd3c5d1c15 CHECK ((char_length(key_hash) <= 64)) -); - -CREATE SEQUENCE ci_test_cases_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE ci_test_cases_id_seq OWNED BY ci_test_cases.id; - CREATE TABLE ci_trigger_requests ( id integer NOT NULL, trigger_id integer NOT NULL, @@ -20074,10 +20042,6 @@ ALTER TABLE ONLY ci_stages ALTER COLUMN id SET DEFAULT nextval('ci_stages_id_seq ALTER TABLE ONLY ci_subscriptions_projects ALTER COLUMN id SET DEFAULT nextval('ci_subscriptions_projects_id_seq'::regclass); -ALTER TABLE ONLY ci_test_case_failures ALTER COLUMN id SET DEFAULT nextval('ci_test_case_failures_id_seq'::regclass); - -ALTER TABLE ONLY ci_test_cases ALTER COLUMN id SET DEFAULT nextval('ci_test_cases_id_seq'::regclass); - ALTER TABLE ONLY ci_trigger_requests ALTER COLUMN id SET DEFAULT nextval('ci_trigger_requests_id_seq'::regclass); ALTER TABLE ONLY ci_triggers ALTER COLUMN id SET DEFAULT nextval('ci_triggers_id_seq'::regclass); @@ -21311,12 +21275,6 @@ ALTER TABLE ONLY ci_stages ALTER TABLE ONLY ci_subscriptions_projects ADD CONSTRAINT ci_subscriptions_projects_pkey PRIMARY KEY (id); -ALTER TABLE ONLY ci_test_case_failures - ADD CONSTRAINT ci_test_case_failures_pkey PRIMARY KEY (id); - -ALTER TABLE ONLY ci_test_cases - ADD CONSTRAINT ci_test_cases_pkey PRIMARY KEY (id); - ALTER TABLE ONLY ci_trigger_requests ADD CONSTRAINT ci_trigger_requests_pkey PRIMARY KEY (id); @@ -23397,10 +23355,6 @@ CREATE INDEX index_ci_subscriptions_projects_on_upstream_project_id ON ci_subscr CREATE UNIQUE INDEX index_ci_subscriptions_projects_unique_subscription ON ci_subscriptions_projects USING btree (downstream_project_id, upstream_project_id); -CREATE INDEX index_ci_test_case_failures_on_build_id ON ci_test_case_failures USING btree (build_id); - -CREATE UNIQUE INDEX index_ci_test_cases_on_project_id_and_key_hash ON ci_test_cases USING btree (project_id, key_hash); - CREATE INDEX index_ci_trigger_requests_on_commit_id ON ci_trigger_requests USING btree (commit_id); CREATE INDEX index_ci_trigger_requests_on_trigger_id_and_id ON ci_trigger_requests USING btree (trigger_id, id DESC); @@ -25205,8 +25159,6 @@ CREATE UNIQUE INDEX index_terraform_states_on_project_id_and_name ON terraform_s CREATE UNIQUE INDEX index_terraform_states_on_uuid ON terraform_states USING btree (uuid); -CREATE UNIQUE INDEX index_test_case_failures_unique_columns ON ci_test_case_failures USING btree (test_case_id, failed_at DESC, build_id); - CREATE INDEX index_timelogs_on_issue_id ON timelogs USING btree (issue_id); CREATE INDEX index_timelogs_on_merge_request_id ON timelogs USING btree (merge_request_id); @@ -25913,9 +25865,6 @@ ALTER TABLE ONLY design_management_designs_versions ALTER TABLE ONLY terraform_state_versions ADD CONSTRAINT fk_04b91e4a9f FOREIGN KEY (ci_build_id) REFERENCES ci_builds(id) ON DELETE SET NULL; -ALTER TABLE ONLY ci_test_cases - ADD CONSTRAINT fk_0526c30ded FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; - ALTER TABLE ONLY issues ADD CONSTRAINT fk_05f1e72feb FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL; @@ -26537,9 +26486,6 @@ ALTER TABLE ONLY ci_sources_pipelines ALTER TABLE ONLY geo_event_log ADD CONSTRAINT fk_d5af95fcd9 FOREIGN KEY (lfs_object_deleted_event_id) REFERENCES geo_lfs_object_deleted_events(id) ON DELETE CASCADE; -ALTER TABLE ONLY ci_test_case_failures - ADD CONSTRAINT fk_d69404d827 FOREIGN KEY (build_id) REFERENCES ci_builds(id) ON DELETE CASCADE; - ALTER TABLE ONLY lists ADD CONSTRAINT fk_d6cf4279f7 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; @@ -28142,9 +28088,6 @@ ALTER TABLE ONLY vulnerability_finding_evidence_sources ALTER TABLE ONLY protected_branch_unprotect_access_levels ADD CONSTRAINT fk_rails_e9eb8dc025 FOREIGN KEY (protected_branch_id) REFERENCES protected_branches(id) ON DELETE CASCADE; -ALTER TABLE ONLY ci_test_case_failures - ADD CONSTRAINT fk_rails_eab6349715 FOREIGN KEY (test_case_id) REFERENCES ci_test_cases(id) ON DELETE CASCADE; - ALTER TABLE ONLY alert_management_alert_user_mentions ADD CONSTRAINT fk_rails_eb2de0cdef FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE; diff --git a/doc/administration/monitoring/ip_whitelist.md b/doc/administration/monitoring/ip_whitelist.md index 20c97a0df8f..b6df176ea87 100644 --- a/doc/administration/monitoring/ip_whitelist.md +++ b/doc/administration/monitoring/ip_whitelist.md @@ -9,7 +9,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w > Introduced in GitLab 9.4. NOTE: -We intend to [rename IP whitelist as `IP allowlist`](https://gitlab.com/gitlab-org/gitlab/-/issues/7554). +We intend to [rename IP whitelist as `IP allowlist`](https://gitlab.com/groups/gitlab-org/-/epics/3478). GitLab provides some [monitoring endpoints](../../user/admin_area/monitoring/health_check.md) that provide health check information when probed. diff --git a/doc/api/packages/debian.md b/doc/api/packages/debian.md index 797955ea600..0912f894fa3 100644 --- a/doc/api/packages/debian.md +++ b/doc/api/packages/debian.md @@ -74,6 +74,38 @@ curl --request PUT \ "https://gitlab.example.com/api/v4/projects/1/packages/debian/mypkg.deb" ``` +## Download a package + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64923) in GitLab 14.2. + +Download a package file. + +```plaintext +GET projects/:id/packages/debian/pool/:distribution/:letter/:package_name/:package_version/:file_name +``` + +| Attribute | Type | Required | Description | +| ----------------- | ------ | -------- | ----------- | +| `distribution` | string | yes | The codename or suite of the Debian distribution. | +| `letter` | string | yes | The Debian Classification (first-letter or lib-first-letter). | +| `package_name` | string | yes | The source package name. | +| `package_version` | string | yes | The source package version. | +| `file_name` | string | yes | The file name. | + +```shell +curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/pool/my-distro/a/my-pkg/1.0.0/example_1.0.0~alpha2_amd64.deb" +``` + +Write the output to a file: + +```shell +curl --header "Private-Token: <personal_access_token>" \ + "https://gitlab.example.com/api/v4/projects/1/packages/pool/my-distro/a/my-pkg/1.0.0/example_1.0.0~alpha2_amd64.deb" \ + --remote-name +``` + +This writes the downloaded file using the remote file name in the current directory. + ## Route prefix The remaining endpoints described are two sets of identical routes that each make requests in @@ -108,7 +140,7 @@ The examples in this document all use the project-level prefix. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64067) in GitLab 14.1. -Download a Debian package file. +Download a Debian distribution file. ```plaintext GET <route-prefix>/dists/*distribution/Release @@ -130,16 +162,13 @@ curl --header "Private-Token: <personal_access_token>" \ --remote-name ``` -This writes the downloaded file to `Release` in the current directory. +This writes the downloaded file using the remote file name in the current directory. ## Download a signed distribution Release file > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64067) in GitLab 14.1. -Download a Debian package file. - -Signed releases are [not supported](https://gitlab.com/groups/gitlab-org/-/epics/6057#note_582697034). -Therefore, this endpoint downloads the unsigned release file. +Download a signed Debian distribution file. ```plaintext GET <route-prefix>/dists/*distribution/InRelease @@ -161,4 +190,62 @@ curl --header "Private-Token: <personal_access_token>" \ --remote-name ``` -This writes the downloaded file to `InRelease` in the current directory. +This writes the downloaded file using the remote file name in the current directory. + +## Download a release file signature + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64923) in GitLab 14.2. + +Download a Debian release file signature. + +```plaintext +GET <route-prefix>/dists/*distribution/Release.gpg +``` + +| Attribute | Type | Required | Description | +| ----------------- | ------ | -------- | ----------- | +| `distribution` | string | yes | The codename or suite of the Debian distribution. | + +```shell +curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/Release.gpg" +``` + +Write the output to a file: + +```shell +curl --header "Private-Token: <personal_access_token>" \ + "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/Release.gpg" \ + --remote-name +``` + +This writes the downloaded file using the remote file name in the current directory. + +## Download a binary file's index + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64923) in GitLab 14.2. + +Download a distribution index. + +```plaintext +GET <route-prefix>/dists/*distribution/:component/binary-:architecture/Packages +``` + +| Attribute | Type | Required | Description | +| ----------------- | ------ | -------- | ----------- | +| `distribution` | string | yes | The codename or suite of the Debian distribution. | +| `component` | string | yes | The distribution component name. | +| `architecture` | string | yes | The distribution architecture type. | + +```shell +curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/amd64/Packages" +``` + +Write the output to a file: + +```shell +curl --header "Private-Token: <personal_access_token>" \ + "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/amd64/Packages" \ + --remote-name +``` + +This writes the downloaded file using the remote file name in the current directory. diff --git a/doc/api/projects.md b/doc/api/projects.md index 240cb844087..fc39036891c 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -149,7 +149,8 @@ When the user is authenticated and `simple` is not set this returns something li "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -238,7 +239,8 @@ When the user is authenticated and `simple` is not set this returns something li "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -425,7 +427,8 @@ GET /users/:user_id/projects "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -514,7 +517,8 @@ GET /users/:user_id/projects "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -663,7 +667,8 @@ Example response: "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -745,7 +750,8 @@ Example response: "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -871,7 +877,8 @@ GET /projects/:id "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "container_expiration_policy": { "cadence": "7d", "enabled": false, @@ -1181,7 +1188,8 @@ POST /projects | `builds_access_level` | string | **{dotted-circle}** No | One of `disabled`, `private`, or `enabled`. | | `ci_config_path` | string | **{dotted-circle}** No | The path to CI configuration file. | | `container_expiration_policy_attributes` | hash | **{dotted-circle}** No | Update the image cleanup policy for this project. Accepts: `cadence` (string), `keep_n` (integer), `older_than` (string), `name_regex` (string), `name_regex_delete` (string), `name_regex_keep` (string), `enabled` (boolean). Valid values for `cadence` are: `1d` (every day), `7d` (every week), `14d` (every two weeks), `1month` (every month), or `3month` (every quarter). | -| `container_registry_enabled` | boolean | **{dotted-circle}** No | Enable container registry for this project. | +| `container_registry_enabled` | boolean | **{dotted-circle}** No | _(Deprecated)_ Enable container registry for this project. Use `container_registry_access_level` instead. | +| `container_registry_access_level` | string | **{dotted-circle}** No | Set visibility of container registry, for this project, to one of `disabled`, `private` or `enabled`. | | `default_branch` | string | **{dotted-circle}** No | The [default branch](../user/project/repository/branches/default.md) name. Requires `initialize_with_readme` to be `true`. | | `description` | string | **{dotted-circle}** No | Short project description. | | `emails_disabled` | boolean | **{dotted-circle}** No | Disable email notifications. | @@ -1256,7 +1264,8 @@ POST /projects/user/:user_id | `build_timeout` | integer | **{dotted-circle}** No | The maximum amount of time, in seconds, that a job can run. | | `builds_access_level` | string | **{dotted-circle}** No | One of `disabled`, `private`, or `enabled`. | | `ci_config_path` | string | **{dotted-circle}** No | The path to CI configuration file. | -| `container_registry_enabled` | boolean | **{dotted-circle}** No | Enable container registry for this project. | +| `container_registry_enabled` | boolean | **{dotted-circle}** No | _(Deprecated)_ Enable container registry for this project. Use `container_registry_access_level` instead. | +| `container_registry_access_level` | string | **{dotted-circle}** No | Set visibility of container registry, for this project, to one of `disabled`, `private` or `enabled`. | | `description` | string | **{dotted-circle}** No | Short project description. | | `default_branch` | string | **{dotted-circle}** No | The [default branch](../user/project/repository/branches/default.md) name. Requires `initialize_with_readme` to be `true`. | | `emails_disabled` | boolean | **{dotted-circle}** No | Disable email notifications. | @@ -1333,7 +1342,8 @@ PUT /projects/:id | `ci_default_git_depth` | integer | **{dotted-circle}** No | Default number of revisions for [shallow cloning](../ci/pipelines/settings.md#limit-the-number-of-changes-fetched-during-clone). | | `ci_forward_deployment_enabled` | boolean | **{dotted-circle}** No | When a new deployment job starts, [skip older deployment jobs](../ci/pipelines/settings.md#skip-outdated-deployment-jobs) that are still pending | | `container_expiration_policy_attributes` | hash | **{dotted-circle}** No | Update the image cleanup policy for this project. Accepts: `cadence` (string), `keep_n` (integer), `older_than` (string), `name_regex` (string), `name_regex_delete` (string), `name_regex_keep` (string), `enabled` (boolean). | -| `container_registry_enabled` | boolean | **{dotted-circle}** No | Enable container registry for this project. | +| `container_registry_enabled` | boolean | **{dotted-circle}** No | _(Deprecated)_ Enable container registry for this project. Use `container_registry_access_level` instead. | +| `container_registry_access_level` | string | **{dotted-circle}** No | Set visibility of container registry, for this project, to one of `disabled`, `private` or `enabled`. | | `default_branch` | string | **{dotted-circle}** No | The [default branch](../user/project/repository/branches/default.md) name. | | `description` | string | **{dotted-circle}** No | Short project description. | | `emails_disabled` | boolean | **{dotted-circle}** No | Disable email notifications. | @@ -1473,7 +1483,8 @@ Example responses: "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -1565,7 +1576,8 @@ Example response: "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -1663,7 +1675,8 @@ Example response: "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -1841,7 +1854,8 @@ Example response: "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -1960,7 +1974,8 @@ Example response: "snippets_enabled": false, "can_create_merge_request_in": true, "resolve_outdated_diff_discussions": false, - "container_registry_enabled": false, + "container_registry_enabled": false, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "disabled", "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -2563,7 +2578,8 @@ Example response: "archived": false, "visibility": "private", "resolve_outdated_diff_discussions": false, - "container_registry_enabled": true, + "container_registry_enabled": true, // deprecated, use container_registry_access_level instead + "container_registry_access_level": "enabled", "container_expiration_policy": { "cadence": "7d", "enabled": false, diff --git a/doc/development/documentation/structure.md b/doc/development/documentation/structure.md index ac934673ee2..a9b93997906 100644 --- a/doc/development/documentation/structure.md +++ b/doc/development/documentation/structure.md @@ -183,8 +183,8 @@ A paragraph that explains what the tutorial does, and the expected outcome. To create a website: -- [Step 1: Do the first task](#do-the-first-task) -- [Step 2: Do the second task](#do-the-second-task) +1. [Do the first task](#do-the-first-task) +1. [Do the second task](#do-the-second-task) Prerequisites (optional): @@ -197,8 +197,8 @@ Prerequisites (optional): To do step 1: 1. First step. -2. Another step. -3. Another step. +1. Another step. +1. Another step. ## Do the second task @@ -207,8 +207,8 @@ Before you begin, make sure you have [done the first task](#do-the-first-task). To do step 2: 1. First step. -2. Another step. -3. Another step. +1. Another step. +1. Another step. ``` ### Get started diff --git a/doc/development/testing_guide/frontend_testing.md b/doc/development/testing_guide/frontend_testing.md index 4c43bcbe871..2a4db4102f1 100644 --- a/doc/development/testing_guide/frontend_testing.md +++ b/doc/development/testing_guide/frontend_testing.md @@ -442,6 +442,18 @@ it('passes', () => { }); ``` +NOTE: +To modify only the hash, use either the `setWindowLocation` helper, or assign +directly to `window.location.hash`, e.g.: + +```javascript +it('passes', () => { + window.location.hash = '#foo'; + + expect(window.location.href).toBe('http://test.host/#foo'); +}); +``` + If your tests need to assert that certain `window.location` methods were called, use the `useMockLocationHelper` helper: diff --git a/doc/user/compliance/license_compliance/img/policies_maintainer_edit_v14_2.png b/doc/user/compliance/license_compliance/img/policies_maintainer_edit_v14_2.png Binary files differindex f4afc5a3fb4..2ad08919f86 100644 --- a/doc/user/compliance/license_compliance/img/policies_maintainer_edit_v14_2.png +++ b/doc/user/compliance/license_compliance/img/policies_maintainer_edit_v14_2.png diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md index ef458db67f0..28b73e6991c 100644 --- a/doc/user/gitlab_com/index.md +++ b/doc/user/gitlab_com/index.md @@ -148,7 +148,7 @@ from those IPs and allow them. GitLab.com is fronted by Cloudflare. For incoming connections to GitLab.com, you might need to allow CIDR blocks of Cloudflare ([IPv4](https://www.cloudflare.com/ips-v4) and [IPv6](https://www.cloudflare.com/ips-v6)). For outgoing connections from CI/CD runners, we are not providing static IP -addresses. All GitLab runners are deployed into Google Cloud Platform (GCP). Any +addresses. All GitLab.com shared runners are deployed into Google Cloud Platform (GCP). Any IP-based firewall can be configured by looking up all [IP address ranges or CIDR blocks for GCP](https://cloud.google.com/compute/docs/faq#find_ip_range). diff --git a/doc/user/packages/debian_repository/index.md b/doc/user/packages/debian_repository/index.md index 9c731d76c4e..789902c03e3 100644 --- a/doc/user/packages/debian_repository/index.md +++ b/doc/user/packages/debian_repository/index.md @@ -61,6 +61,15 @@ Feature.disable(:debian_group_packages) Creating a Debian package is documented [on the Debian Wiki](https://wiki.debian.org/Packaging). +## Authenticate to the Package Registry + +To create a distribution, publish a package, or install a private package, you need one of the +following: + +- [Personal access token](../../../api/index.md#personalproject-access-tokens) +- [CI/CD job token](../../../api/index.md#gitlab-cicd-job-token) +- [Deploy token](../../project/deploy_tokens/index.md) + ## Create a Distribution On the project-level, Debian packages are published using *Debian Distributions*. To publish @@ -116,7 +125,7 @@ To upload these files, you can use `dput-ng >= 1.32` (Debian bullseye): cat <<EOF > dput.cf [gitlab] method = https -fqdn = <login>:<your_access_token>@gitlab.example.com +fqdn = <username>:<your_access_token>@gitlab.example.com incoming = /api/v4/projects/<project_id>/packages/debian EOF @@ -125,5 +134,27 @@ dput --config=dput.cf --unchecked --no-upload-log gitlab <your_package>.changes ## Install a package -The Debian package registry for GitLab is under development, and isn't ready for production use. You -cannot install packages from the registry. However, you can download files directly from the UI. +To install a package: + +1. Configure the repository: + + If you are using a private project, add your [credentials](#authenticate-to-the-package-registry) to your apt config: + + ```shell + echo 'machine gitlab.example.com login <username> password <your_access_token>' \ + | sudo tee /etc/apt/auth.conf.d/gitlab_project.conf + ``` + + Add your project as a source: + + ```shell + echo 'deb [trusted=yes] https://gitlab.example.com/api/v4/projects/<project_id>/packages/debian <codename> <component1> <component2>' \ + | sudo tee /etc/apt/sources.list.d/gitlab_project.list + sudo apt-get update + ``` + +1. Install the package: + + ```shell + sudo apt-get -y install -t <codename> <package-name> + ``` diff --git a/lib/api/entities/project.rb b/lib/api/entities/project.rb index f5f565e5b07..890b42ed8c8 100644 --- a/lib/api/entities/project.rb +++ b/lib/api/entities/project.rb @@ -71,6 +71,7 @@ module API expose(:pages_access_level) { |project, options| project.project_feature.string_access_level(:pages) } expose(:operations_access_level) { |project, options| project.project_feature.string_access_level(:operations) } expose(:analytics_access_level) { |project, options| project.project_feature.string_access_level(:analytics) } + expose(:container_registry_access_level) { |project, options| project.project_feature.string_access_level(:container_registry) } expose :emails_disabled expose :shared_runners_enabled diff --git a/lib/api/helpers/projects_helpers.rb b/lib/api/helpers/projects_helpers.rb index 0fdd6c141a9..becd25595a6 100644 --- a/lib/api/helpers/projects_helpers.rb +++ b/lib/api/helpers/projects_helpers.rb @@ -35,13 +35,14 @@ module API optional :pages_access_level, type: String, values: %w(disabled private enabled public), desc: 'Pages access level. One of `disabled`, `private`, `enabled` or `public`' optional :operations_access_level, type: String, values: %w(disabled private enabled), desc: 'Operations access level. One of `disabled`, `private` or `enabled`' optional :analytics_access_level, type: String, values: %w(disabled private enabled), desc: 'Analytics access level. One of `disabled`, `private` or `enabled`' + optional :container_registry_access_level, type: String, values: %w(disabled private enabled), desc: 'Controls visibility of the container registry. One of `disabled`, `private` or `enabled`. `private` will make the container registry accessible only to project members (reporter role and above). `enabled` will make the container registry accessible to everyone who has access to the project. `disabled` will disable the container registry' optional :emails_disabled, type: Boolean, desc: 'Disable email notifications' optional :show_default_award_emojis, type: Boolean, desc: 'Show default award emojis' optional :shared_runners_enabled, type: Boolean, desc: 'Flag indication if shared runners are enabled for that project' optional :resolve_outdated_diff_discussions, type: Boolean, desc: 'Automatically resolve merge request diffs discussions on lines changed with a push' optional :remove_source_branch_after_merge, type: Boolean, desc: 'Remove the source branch by default after merge' - optional :container_registry_enabled, type: Boolean, desc: 'Flag indication if the container registry is enabled for that project' + optional :container_registry_enabled, type: Boolean, desc: 'Deprecated: Use :container_registry_access_level instead. Flag indication if the container registry is enabled for that project' optional :container_expiration_policy_attributes, type: Hash do use :optional_container_expiration_policy_params end @@ -124,7 +125,7 @@ module API :ci_config_path, :ci_default_git_depth, :ci_forward_deployment_enabled, - :container_registry_enabled, + :container_registry_access_level, :container_expiration_policy_attributes, :default_branch, :description, @@ -169,7 +170,8 @@ module API :jobs_enabled, :merge_requests_enabled, :wiki_enabled, - :snippets_enabled + :snippets_enabled, + :container_registry_enabled ] end diff --git a/lib/gitlab/pagination/keyset/column_condition_builder.rb b/lib/gitlab/pagination/keyset/column_condition_builder.rb new file mode 100644 index 00000000000..ca436000abe --- /dev/null +++ b/lib/gitlab/pagination/keyset/column_condition_builder.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +module Gitlab + module Pagination + module Keyset + class ColumnConditionBuilder + # This class builds the WHERE conditions for the keyset pagination library. + # It produces WHERE conditions for one column at a time. + # + # Requisite 1: Only the last column (columns.last) is non-nullable and distinct. + # Requisite 2: Only one column is distinct and non-nullable. + # + # Scenario: We want to order by columns named X, Y and Z and build the conditions + # used in the WHERE clause of a pagination query using a set of cursor values. + # X is the column definition for a nullable column + # Y is the column definition for a non-nullable but not distinct column + # Z is the column definition for a distinct, non-nullable column used as a tie breaker. + # + # Then the method is initially invoked with these arguments: + # columns = [ColumnDefinition for X, ColumnDefinition for Y, ColumnDefinition for Z] + # values = { X: x, Y: y, Z: z } => these represent cursor values for pagination + # (x could be nil since X is nullable) + # current_conditions is initialized to [] to store the result during the iteration calls + # invoked within the Order#build_where_values method. + # + # The elements of current_conditions are instances of Arel::Nodes and - + # will be concatenated using OR or UNION to be used in the WHERE clause. + # + # Example: Let's say we want to build WHERE clause conditions for + # ORDER BY X DESC NULLS LAST, Y ASC, Z DESC + # + # Iteration 1: + # columns = [X, Y, Z] + # At the end, current_conditions should be: + # [(Z < z)] + # + # Iteration 2: + # columns = [X, Y] + # At the end, current_conditions should be: + # [(Y > y) OR (Y = y AND Z < z)] + # + # Iteration 3: + # columns = [X] + # At the end, current_conditions should be: + # [((X IS NOT NULL AND Y > y) OR (X IS NOT NULL AND Y = y AND Z < z)) + # OR + # ((x IS NULL) OR (X IS NULL))] + # + # Parameters: + # + # - columns: instance of ColumnOrderDefinition + # - value: cursor value for the column + def initialize(column, value) + @column = column + @value = value + end + + def where_conditions(current_conditions) + return not_nullable_conditions(current_conditions) if column.not_nullable? + return nulls_first_conditions(current_conditions) if column.nulls_first? + + # Here we are dealing with the case of column_definition.nulls_last? + # Suppose ORDER BY X DESC NULLS FIRST, Y ASC, Z DESC is the ordering clause + # and we already have built the conditions for columns Y and Z. + # + # We first need a set of conditions to use when x (the value for X) is NULL: + # null_conds = [ + # (x IS NULL AND X IS NULL AND Y<y), + # (x IS NULL AND X IS NULL AND Y=y AND Z<z), + null_conds = current_conditions.map do |conditional| + Arel::Nodes::And.new([value_is_null, column_is_null, conditional]) + end + + # We then need a set of conditions to use when m has an actual value: + # non_null_conds = [ + # (x IS NOT NULL AND X IS NULL), + # (x IS NOT NULL AND X < x) + # (x IS NOT NULL AND X = x AND Y > y), + # (x IS NOT NULL AND X = x AND Y = y AND Z < z), + tie_breaking_conds = current_conditions.map do |conditional| + Arel::Nodes::And.new([column_equals_to_value, conditional]) + end + + non_null_conds = [column_is_null, compare_column_with_value, *tie_breaking_conds].map do |conditional| + Arel::Nodes::And.new([value_is_not_null, conditional]) + end + + [*null_conds, *non_null_conds] + end + + private + + # WHEN THE COLUMN IS NON-NULLABLE AND DISTINCT + # Per Assumption 1, only the last column can be non-nullable and distinct + # (column Z is non-nullable/distinct and comes last in the example). + # So the Order#build_where_conditions is being called for the first time with current_conditions = []. + # + # At the end of the call, we should expect: + # current_conditions should be [(Z < z)] + # + # WHEN THE COLUMN IS NON-NULLABLE BUT NOT DISTINCT + # Let's say Z has been processed and we are about to process the column Y next. + # (per requisite 1, if a non-nullable but not distinct column is being processed, + # at the least, the conditional for the non-nullable/distinct column exists) + # + # At the start of the method call: + # current_conditions = [(Z < z)] + # comparison_node = (Y < y) + # eqaulity_node = (Y = y) + # + # We should add a comparison node for the next column Y, (Y < y) + # then break a tie using the previous conditionals, (Y = y AND Z < z) + # + # At the end of the call, we should expect: + # current_conditions = [(Y < y), (Y = y AND Z < z)] + def not_nullable_conditions(current_conditions) + tie_break_conds = current_conditions.map do |conditional| + Arel::Nodes::And.new([column_equals_to_value, conditional]) + end + + [compare_column_with_value, *tie_break_conds] + end + + def nulls_first_conditions(current_conditions) + # Using the same scenario described earlier, + # suppose the ordering clause is ORDER BY X DESC NULLS FIRST, Y ASC, Z DESC + # and we have built the conditions for columns Y and Z in previous iterations: + # + # current_conditions = [(Y > y), (Y = y AND Z < z)] + # + # In this branch of the iteration, + # we first need a set of conditions to use when m (the value for M) is NULL: + # null_conds = [ + # (x IS NULL AND X IS NULL AND Y > y), + # (x IS NULL AND X IS NULL AND Y = y AND Z < z), + # (x IS NULL AND X IS NOT NULL)] + # + # Note that when x has an actual value, say x = 3, null_conds evalutes to FALSE. + tie_breaking_conds = current_conditions.map do |conditional| + Arel::Nodes::And.new([column_is_null, conditional]) + end + + null_conds = [*tie_breaking_conds, column_is_not_null].map do |conditional| + Arel::Nodes::And.new([value_is_null, conditional]) + end + + # We then need a set of conditions to use when m has an actual value: + # non_null_conds = [ + # (x IS NOT NULL AND X < x), + # (x IS NOT NULL AND X = x AND Y > y), + # (x IS NOT NULL AND X = x AND Y = y AND Z < z)] + # + # Note again that when x IS NULL, non_null_conds evaluates to FALSE. + tie_breaking_conds = current_conditions.map do |conditional| + Arel::Nodes::And.new([column_equals_to_value, conditional]) + end + + # The combined OR condition (null_where_cond OR non_null_where_cond) will return a correct result - + # without having to account for whether x is nil or an actual value at the application level. + non_null_conds = [compare_column_with_value, *tie_breaking_conds].map do |conditional| + Arel::Nodes::And.new([value_is_not_null, conditional]) + end + + [*null_conds, *non_null_conds] + end + + def column_equals_to_value + @equality_node ||= column.column_expression.eq(value) + end + + def column_is_null + @column_is_null ||= column.column_expression.eq(nil) + end + + def column_is_not_null + @column_is_not_null ||= column.column_expression.not_eq(nil) + end + + def value_is_null + @value_is_null ||= build_quoted_value.eq(nil) + end + + def value_is_not_null + @value_is_not_null ||= build_quoted_value.not_eq(nil) + end + + def compare_column_with_value + if column.descending_order? + column.column_expression.lt(value) + else + column.column_expression.gt(value) + end + end + + # Turns the given value to an SQL literal by casting it to the proper format. + def build_quoted_value + return value if value.instance_of?(Arel::Nodes::SqlLiteral) + + Arel::Nodes.build_quoted(value, column.column_expression) + end + + attr_reader :column, :value + end + end + end +end diff --git a/lib/gitlab/pagination/keyset/order.rb b/lib/gitlab/pagination/keyset/order.rb index 19d44ee69dd..ccfa9334a12 100644 --- a/lib/gitlab/pagination/keyset/order.rb +++ b/lib/gitlab/pagination/keyset/order.rb @@ -141,24 +141,10 @@ module Gitlab return use_composite_row_comparison(values) if composite_row_comparison_possible? - where_values = [] - - reversed_column_definitions = column_definitions.reverse - reversed_column_definitions.each_with_index do |column_definition, i| - value = values[column_definition.attribute_name] - - conditions_for_column(column_definition, value).each do |condition| - column_definitions_after_index = reversed_column_definitions.last(column_definitions.reverse.size - i - 1) - - equal_conditon_for_rest = column_definitions_after_index.map do |definition| - definition.column_expression.eq(values[definition.attribute_name]) - end - - where_values << Arel::Nodes::Grouping.new(Arel::Nodes::And.new([condition, *equal_conditon_for_rest].compact)) - end - end - - where_values + column_definitions + .map { ColumnConditionBuilder.new(_1, values[_1.attribute_name]) } + .reverse + .reduce([]) { |where_conditions, column| column.where_conditions(where_conditions) } end def where_values_with_or_query(values) @@ -222,32 +208,6 @@ module Gitlab scope end - def conditions_for_column(column_definition, value) - conditions = [] - # Depending on the order, build a query condition fragment for taking the next rows - if column_definition.distinct? || (!column_definition.distinct? && value.present?) - conditions << compare_column_with_value(column_definition, value) - end - - # When the column is nullable, additional conditions for NULL a NOT NULL values are necessary. - # This depends on the position of the nulls (top or bottom of the resultset). - if column_definition.nulls_first? && value.blank? - conditions << column_definition.column_expression.not_eq(nil) - elsif column_definition.nulls_last? && value.present? - conditions << column_definition.column_expression.eq(nil) - end - - conditions - end - - def compare_column_with_value(column_definition, value) - if column_definition.descending_order? - column_definition.column_expression.lt(value) - else - column_definition.column_expression.gt(value) - end - end - def build_or_query(expressions) return [] if expressions.blank? diff --git a/spec/frontend/authentication/two_factor_auth/index_spec.js b/spec/frontend/authentication/two_factor_auth/index_spec.js index f5345139021..0ff9d60f409 100644 --- a/spec/frontend/authentication/two_factor_auth/index_spec.js +++ b/spec/frontend/authentication/two_factor_auth/index_spec.js @@ -1,5 +1,6 @@ import { getByTestId, fireEvent } from '@testing-library/dom'; import { createWrapper } from '@vue/test-utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { initRecoveryCodes, initClose2faSuccessMessage } from '~/authentication/two_factor_auth'; import RecoveryCodes from '~/authentication/two_factor_auth/components/recovery_codes.vue'; import * as urlUtils from '~/lib/utils/url_utility'; @@ -53,8 +54,7 @@ describe('initClose2faSuccessMessage', () => { describe('when alert is closed', () => { beforeEach(() => { - delete window.location; - window.location = new URL( + setWindowLocation( 'https://localhost/-/profile/account?two_factor_auth_enabled_successfully=true', ); diff --git a/spec/frontend/authentication/webauthn/error_spec.js b/spec/frontend/authentication/webauthn/error_spec.js index 26f1ca5e27d..9b71f77dde2 100644 --- a/spec/frontend/authentication/webauthn/error_spec.js +++ b/spec/frontend/authentication/webauthn/error_spec.js @@ -1,3 +1,4 @@ +import setWindowLocation from 'helpers/set_window_location_helper'; import WebAuthnError from '~/authentication/webauthn/error'; describe('WebAuthnError', () => { @@ -17,19 +18,8 @@ describe('WebAuthnError', () => { }); describe('SecurityError', () => { - const { location } = window; - - beforeEach(() => { - delete window.location; - window.location = {}; - }); - - afterEach(() => { - window.location = location; - }); - it('returns a descriptive error if https is disabled', () => { - window.location.protocol = 'http:'; + setWindowLocation('http://localhost'); const expectedMessage = 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.'; @@ -39,7 +29,7 @@ describe('WebAuthnError', () => { }); it('returns a generic error if https is enabled', () => { - window.location.protocol = 'https:'; + setWindowLocation('https://localhost'); const expectedMessage = 'There was a problem communicating with your device.'; expect( diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js index 43cd3d7ca34..0f8ea2b635f 100644 --- a/spec/frontend/authentication/webauthn/register_spec.js +++ b/spec/frontend/authentication/webauthn/register_spec.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WebAuthnRegister from '~/authentication/webauthn/register'; import MockWebAuthnDevice from './mock_webauthn_device'; @@ -50,17 +51,14 @@ describe('WebAuthnRegister', () => { }); describe('when unsupported', () => { - const { location, PublicKeyCredential } = window; + const { PublicKeyCredential } = window; beforeEach(() => { - delete window.location; delete window.credentials; - window.location = {}; window.PublicKeyCredential = undefined; }); afterEach(() => { - window.location = location; window.PublicKeyCredential = PublicKeyCredential; }); @@ -69,7 +67,7 @@ describe('WebAuthnRegister', () => { ${false} | ${'WebAuthn only works with HTTPS-enabled websites'} ${true} | ${'Please use a supported browser, e.g. Chrome (67+) or Firefox'} `('when https is $httpsEnabled', ({ httpsEnabled, expectedText }) => { - window.location.protocol = httpsEnabled ? 'https:' : 'http:'; + setWindowLocation(`${httpsEnabled ? 'https:' : 'http:'}//localhost`); component.start(); expect(findMessage().text()).toContain(expectedText); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 87f9a68f5dd..555767dd549 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -1,6 +1,7 @@ import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { range } from 'lodash'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; @@ -363,8 +364,6 @@ describe('Board card component', () => { describe('filterByLabel method', () => { beforeEach(() => { - delete window.location; - wrapper.setProps({ updateFilters: true, }); @@ -373,7 +372,7 @@ describe('Board card component', () => { describe('when selected label is not in the filter', () => { beforeEach(() => { jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(() => {}); - window.location = { search: '' }; + setWindowLocation('?'); wrapper.vm.filterByLabel(label1); }); @@ -394,7 +393,7 @@ describe('Board card component', () => { describe('when selected label is already in the filter', () => { beforeEach(() => { jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(() => {}); - window.location = { search: '?label_name[]=testing%20123' }; + setWindowLocation('?label_name[]=testing%20123'); wrapper.vm.filterByLabel(label1); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 3966c3e6b87..52f1907654a 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -1,5 +1,6 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -75,10 +76,6 @@ describe('BoardForm', () => { }); }; - beforeEach(() => { - delete window.location; - }); - afterEach(() => { wrapper.destroy(); wrapper = null; @@ -244,7 +241,7 @@ describe('BoardForm', () => { updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } }, }, }); - window.location = new URL('https://test/boards/1'); + setWindowLocation('https://test/boards/1'); createComponent({ canAdminBoard: true, currentPage: formType.edit }); findInput().trigger('keyup.enter', { metaKey: true }); @@ -270,7 +267,7 @@ describe('BoardForm', () => { updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } }, }, }); - window.location = new URL('https://test/boards/1?group_by=epic'); + setWindowLocation('https://test/boards/1?group_by=epic'); createComponent({ canAdminBoard: true, currentPage: formType.edit }); findInput().trigger('keyup.enter', { metaKey: true }); diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index 42990334f0a..2a0610b1b0a 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { loadHTMLFixture } from 'helpers/fixtures'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { setTestTimeout } from 'helpers/timeout'; import Clusters from '~/clusters/clusters_bundle'; import axios from '~/lib/utils/axios_utils'; @@ -8,6 +9,8 @@ import initProjectSelectDropdown from '~/project_select'; jest.mock('~/lib/utils/poll'); jest.mock('~/project_select'); +useMockLocationHelper(); + describe('Clusters', () => { setTestTimeout(1000); @@ -55,20 +58,6 @@ describe('Clusters', () => { }); describe('updateContainer', () => { - const { location } = window; - - beforeEach(() => { - delete window.location; - window.location = { - reload: jest.fn(), - hash: location.hash, - }; - }); - - afterEach(() => { - window.location = location; - }); - describe('when creating cluster', () => { it('should show the creating container', () => { cluster.updateContainer(null, 'creating'); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index b5eb3e1713c..bc06990e03a 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; import Mousetrap from 'mousetrap'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'spec/test_constants'; import App from '~/diffs/components/app.vue'; import CommitWidget from '~/diffs/components/commit_widget.vue'; @@ -428,12 +429,10 @@ describe('diffs/components/app', () => { jest.spyOn(wrapper.vm, 'refetchDiffData').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'adjustView').mockImplementation(() => {}); }; - let location; + const location = window.location.href; beforeAll(() => { - location = window.location; - delete window.location; - window.location = COMMIT_URL; + setWindowLocation(COMMIT_URL); document.title = 'My Title'; }); @@ -442,7 +441,7 @@ describe('diffs/components/app', () => { }); afterAll(() => { - window.location = location; + setWindowLocation(location); }); it('when the commit changes and the app is not loading it should update the history, refetch the diff data, and update the view', async () => { diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index 1697ea3e952..1c0cb1193fa 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -220,7 +220,7 @@ describe('CompareVersions', () => { describe('prev commit', () => { beforeAll(() => { - setWindowLocation(`${TEST_HOST}?commit_id=${mrCommit.id}`); + setWindowLocation(`?commit_id=${mrCommit.id}`); }); beforeEach(() => { @@ -255,7 +255,7 @@ describe('CompareVersions', () => { describe('next commit', () => { beforeAll(() => { - setWindowLocation(`${TEST_HOST}?commit_id=${mrCommit.id}`); + setWindowLocation(`?commit_id=${mrCommit.id}`); }); beforeEach(() => { diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js index 352db9d0d51..2c06ae03892 100644 --- a/spec/frontend/editor/source_editor_extension_base_spec.js +++ b/spec/frontend/editor/source_editor_extension_base_spec.js @@ -1,5 +1,6 @@ import { Range } from 'monaco-editor'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE, @@ -152,12 +153,7 @@ describe('The basis for an Source Editor extension', () => { useFakeRequestAnimationFrame(); beforeEach(() => { - delete window.location; - window.location = new URL(`https://localhost`); - }); - - afterEach(() => { - window.location.hash = ''; + setWindowLocation('https://localhost'); }); it.each` diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js index a3b91cb20bb..3f47fa024bc 100644 --- a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js +++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js @@ -1,11 +1,23 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { redirectTo } from '~/lib/utils/url_utility'; import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +jest.mock('~/lib/utils/url_utility', () => { + const urlUtility = jest.requireActual('~/lib/utils/url_utility'); + + return { + __esModule: true, + ...urlUtility, + redirectTo: jest.fn(), + }; +}); + const localVue = createLocalVue(); localVue.use(Vuex); @@ -113,12 +125,11 @@ describe('MembersFilteredSearchBar', () => { describe('when filters are set via query params', () => { beforeEach(() => { - delete window.location; - window.location = new URL('https://localhost'); + setWindowLocation('https://localhost'); }); it('parses and passes tokens to `FilteredSearchBar` component as `initialFilterValue` prop', () => { - window.location.search = '?two_factor=enabled&token_not_available=foobar'; + setWindowLocation('?two_factor=enabled&token_not_available=foobar'); createComponent(); @@ -134,7 +145,7 @@ describe('MembersFilteredSearchBar', () => { }); it('parses and passes search param to `FilteredSearchBar` component as `initialFilterValue` prop', () => { - window.location.search = '?search=foobar'; + setWindowLocation('?search=foobar'); createComponent(); @@ -149,7 +160,7 @@ describe('MembersFilteredSearchBar', () => { }); it('parses and passes search param with multiple words to `FilteredSearchBar` component as `initialFilterValue` prop', () => { - window.location.search = '?search=foo+bar+baz'; + setWindowLocation('?search=foo+bar+baz'); createComponent(); @@ -166,8 +177,7 @@ describe('MembersFilteredSearchBar', () => { describe('when filter bar is submitted', () => { beforeEach(() => { - delete window.location; - window.location = new URL('https://localhost'); + setWindowLocation('https://localhost'); }); it('adds correct filter query params', () => { @@ -177,7 +187,7 @@ describe('MembersFilteredSearchBar', () => { { type: 'two_factor', value: { data: 'enabled', operator: '=' } }, ]); - expect(window.location.href).toBe('https://localhost/?two_factor=enabled'); + expect(redirectTo).toHaveBeenCalledWith('https://localhost/?two_factor=enabled'); }); it('adds search query param', () => { @@ -188,7 +198,9 @@ describe('MembersFilteredSearchBar', () => { { type: 'filtered-search-term', value: { data: 'foobar' } }, ]); - expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foobar'); + expect(redirectTo).toHaveBeenCalledWith( + 'https://localhost/?two_factor=enabled&search=foobar', + ); }); it('adds search query param with multiple words', () => { @@ -199,11 +211,13 @@ describe('MembersFilteredSearchBar', () => { { type: 'filtered-search-term', value: { data: 'foo bar baz' } }, ]); - expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foo+bar+baz'); + expect(redirectTo).toHaveBeenCalledWith( + 'https://localhost/?two_factor=enabled&search=foo+bar+baz', + ); }); it('adds sort query param', () => { - window.location.search = '?sort=name_asc'; + setWindowLocation('?sort=name_asc'); createComponent(); @@ -212,13 +226,13 @@ describe('MembersFilteredSearchBar', () => { { type: 'filtered-search-term', value: { data: 'foobar' } }, ]); - expect(window.location.href).toBe( + expect(redirectTo).toHaveBeenCalledWith( 'https://localhost/?two_factor=enabled&search=foobar&sort=name_asc', ); }); it('adds active tab query param', () => { - window.location.search = '?tab=invited'; + setWindowLocation('?tab=invited'); createComponent(); @@ -226,7 +240,7 @@ describe('MembersFilteredSearchBar', () => { { type: 'filtered-search-term', value: { data: 'foobar' } }, ]); - expect(window.location.href).toBe('https://localhost/?search=foobar&tab=invited'); + expect(redirectTo).toHaveBeenCalledWith('https://localhost/?search=foobar&tab=invited'); }); }); }); diff --git a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js index 4b335755980..d0684acd487 100644 --- a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js +++ b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js @@ -1,6 +1,7 @@ import { GlSorting, GlSortingItem } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import * as urlUtilities from '~/lib/utils/url_utility'; import SortDropdown from '~/members/components/filter_sort/sort_dropdown.vue'; import { MEMBER_TYPES } from '~/members/constants'; @@ -52,17 +53,16 @@ describe('SortDropdown', () => { .findAll(GlSortingItem) .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text); - describe('dropdown options', () => { - beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); - }); + beforeEach(() => { + setWindowLocation(URL_HOST); + }); + describe('dropdown options', () => { it('adds dropdown items for all the sortable fields', () => { const URL_FILTER_PARAMS = '?two_factor=enabled&search=foobar'; const EXPECTED_BASE_URL = `${URL_HOST}${URL_FILTER_PARAMS}&sort=`; - window.location.search = URL_FILTER_PARAMS; + setWindowLocation(URL_FILTER_PARAMS); const expectedDropdownItems = [ { @@ -94,7 +94,7 @@ describe('SortDropdown', () => { }); it('checks selected sort option', () => { - window.location.search = '?sort=access_level_asc'; + setWindowLocation('?sort=access_level_asc'); createComponent(); @@ -103,11 +103,6 @@ describe('SortDropdown', () => { }); describe('dropdown toggle', () => { - beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); - }); - it('defaults to sorting by "Account" in ascending order', () => { createComponent(); @@ -116,7 +111,7 @@ describe('SortDropdown', () => { }); it('sets text as selected sort option', () => { - window.location.search = '?sort=access_level_asc'; + setWindowLocation('?sort=access_level_asc'); createComponent(); @@ -126,15 +121,12 @@ describe('SortDropdown', () => { describe('sort direction toggle', () => { beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); - - jest.spyOn(urlUtilities, 'visitUrl'); + jest.spyOn(urlUtilities, 'visitUrl').mockImplementation(); }); describe('when current sort direction is ascending', () => { beforeEach(() => { - window.location.search = '?sort=access_level_asc'; + setWindowLocation('?sort=access_level_asc'); createComponent(); }); @@ -152,7 +144,7 @@ describe('SortDropdown', () => { describe('when current sort direction is descending', () => { beforeEach(() => { - window.location.search = '?sort=access_level_desc'; + setWindowLocation('?sort=access_level_desc'); createComponent(); }); diff --git a/spec/frontend/members/components/members_tabs_spec.js b/spec/frontend/members/components/members_tabs_spec.js index 33d8eebf7eb..68f25bcb619 100644 --- a/spec/frontend/members/components/members_tabs_spec.js +++ b/spec/frontend/members/components/members_tabs_spec.js @@ -1,6 +1,7 @@ import { GlTabs } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import MembersApp from '~/members/components/app.vue'; import MembersTabs from '~/members/components/members_tabs.vue'; @@ -90,8 +91,7 @@ describe('MembersTabs', () => { const findActiveTab = () => wrapper.findByRole('tab', { selected: true }); beforeEach(() => { - delete window.location; - window.location = new URL('https://localhost'); + setWindowLocation('https://localhost'); }); afterEach(() => { @@ -151,7 +151,7 @@ describe('MembersTabs', () => { describe('when url param matches `filteredSearchBar.searchParam`', () => { beforeEach(() => { - window.location.search = '?search_groups=foo+bar'; + setWindowLocation('?search_groups=foo+bar'); }); it('shows tab that corresponds to search param', async () => { diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 6e7d9bc6b6e..6885da53b26 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -6,6 +6,7 @@ import { } from '@testing-library/dom'; import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import CreatedAt from '~/members/components/table/created_at.vue'; import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; @@ -243,12 +244,8 @@ describe('MembersTable', () => { }); describe('when required pagination data is provided', () => { - beforeEach(() => { - delete window.location; - }); - it('renders `gl-pagination` component with correct props', () => { - window.location = new URL(url); + setWindowLocation(url); createComponent(); @@ -268,7 +265,7 @@ describe('MembersTable', () => { }); it('uses `pagination.paramName` to generate the pagination links', () => { - window.location = new URL(url); + setWindowLocation(url); createComponent({ pagination: { @@ -283,7 +280,7 @@ describe('MembersTable', () => { }); it('removes any url params defined as `null` in the `params` attribute', () => { - window.location = new URL(`${url}&search_groups=foo`); + setWindowLocation(`${url}&search_groups=foo`); createComponent({ pagination: { diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 9740e1c2edb..a157cfa1c1d 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -1,3 +1,4 @@ +import setWindowLocation from 'helpers/set_window_location_helper'; import { DEFAULT_SORT, MEMBER_TYPES } from '~/members/constants'; import { generateBadges, @@ -150,21 +151,18 @@ describe('Members Utils', () => { describe('parseSortParam', () => { beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); + setWindowLocation(URL_HOST); }); describe('when `sort` param is not present', () => { it('returns default sort options', () => { - window.location.search = ''; - expect(parseSortParam(['account'])).toEqual(DEFAULT_SORT); }); }); describe('when field passed in `sortableFields` argument does not have `sort` key defined', () => { it('returns default sort options', () => { - window.location.search = '?sort=source_asc'; + setWindowLocation('?sort=source_asc'); expect(parseSortParam(['source'])).toEqual(DEFAULT_SORT); }); @@ -182,7 +180,7 @@ describe('Members Utils', () => { ${'oldest_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: true }} `('when `sort` query string param is `$sortParam`', ({ sortParam, expected }) => { it(`returns ${JSON.stringify(expected)}`, async () => { - window.location.search = `?sort=${sortParam}`; + setWindowLocation(`?sort=${sortParam}`); expect(parseSortParam(['account', 'granted', 'expires', 'maxRole', 'lastSignIn'])).toEqual( expected, @@ -193,8 +191,7 @@ describe('Members Utils', () => { describe('buildSortHref', () => { beforeEach(() => { - delete window.location; - window.location = new URL(URL_HOST); + setWindowLocation(URL_HOST); }); describe('when field passed in `sortBy` argument does not have `sort` key defined', () => { @@ -225,7 +222,7 @@ describe('Members Utils', () => { describe('when filter params are set', () => { it('merges the `sort` param with the filter params', () => { - window.location.search = '?two_factor=enabled&with_inherited_permissions=exclude'; + setWindowLocation('?two_factor=enabled&with_inherited_permissions=exclude'); expect( buildSortHref({ @@ -240,7 +237,7 @@ describe('Members Utils', () => { describe('when search param is set', () => { it('merges the `sort` param with the search param', () => { - window.location.search = '?search=foobar'; + setWindowLocation('?search=foobar'); expect( buildSortHref({ diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js index dbb9fd5f603..f2116c1f478 100644 --- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js +++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js @@ -301,9 +301,6 @@ describe('Actions menu', () => { }); it('redirects to the newly created dashboard', () => { - delete window.location; - window.location = new URL('https://localhost'); - const newDashboard = dashboardGitResponse[1]; const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml'; diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 7ca1b97d849..f899580b3df 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import VueDraggable from 'vuedraggable'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createFlash from '~/flash'; @@ -226,32 +227,25 @@ describe('Dashboard', () => { }); describe('when the URL contains a reference to a panel', () => { - let location; + const location = window.location.href; - const setSearch = (search) => { - window.location = { ...location, search }; + const setSearch = (searchParams) => { + setWindowLocation(`?${objectToQuery(searchParams)}`); }; - beforeEach(() => { - location = window.location; - delete window.location; - }); - afterEach(() => { - window.location = location; + setWindowLocation(location); }); it('when the URL points to a panel it expands', () => { const panelGroup = metricsDashboardViewModel.panelGroups[0]; const panel = panelGroup.panels[0]; - setSearch( - objectToQuery({ - group: panelGroup.group, - title: panel.title, - y_label: panel.y_label, - }), - ); + setSearch({ + group: panelGroup.group, + title: panel.title, + y_label: panel.y_label, + }); createMountedWrapper({ hasMetrics: true }); setupStoreWithData(store); @@ -268,7 +262,7 @@ describe('Dashboard', () => { }); it('when the URL does not link to any panel, no panel is expanded', () => { - setSearch(''); + setSearch(); createMountedWrapper({ hasMetrics: true }); setupStoreWithData(store); @@ -285,13 +279,11 @@ describe('Dashboard', () => { const panelGroup = metricsDashboardViewModel.panelGroups[0]; const panel = panelGroup.panels[0]; - setSearch( - objectToQuery({ - group: panelGroup.group, - title: 'incorrect', - y_label: panel.y_label, - }), - ); + setSearch({ + group: panelGroup.group, + title: 'incorrect', + y_label: panel.y_label, + }); createMountedWrapper({ hasMetrics: true }); setupStoreWithData(store); diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js index 3132ec61942..377e7e05f09 100644 --- a/spec/frontend/packages/details/components/app_spec.js +++ b/spec/frontend/packages/details/components/app_spec.js @@ -2,6 +2,7 @@ import { GlEmptyState } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; import Vuex from 'vuex'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import stubChildren from 'helpers/stub_children'; import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue'; @@ -30,6 +31,8 @@ import { const localVue = createLocalVue(); localVue.use(Vuex); +useMockLocationHelper(); + describe('PackagesApp', () => { let wrapper; let store; @@ -37,7 +40,6 @@ describe('PackagesApp', () => { const deletePackage = jest.fn(); const deletePackageFile = jest.fn(); const defaultProjectName = 'bar'; - const { location } = window; function createComponent({ packageEntity = mavenPackage, @@ -100,14 +102,8 @@ describe('PackagesApp', () => { const findInstallationCommands = () => wrapper.find(InstallationCommands); const findPackageFiles = () => wrapper.find(PackageFiles); - beforeEach(() => { - delete window.location; - window.location = { replace: jest.fn() }; - }); - afterEach(() => { wrapper.destroy(); - window.location = location; }); it('renders the app and displays the package title', async () => { diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js index 4de2dd0789e..b94192c531c 100644 --- a/spec/frontend/packages/list/components/packages_list_app_spec.js +++ b/spec/frontend/packages/list/components/packages_list_app_spec.js @@ -1,6 +1,7 @@ import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; import createFlash from '~/flash'; import * as commonUtils from '~/lib/utils/common_utils'; import PackageListApp from '~/packages/list/components/packages_list_app.vue'; @@ -233,21 +234,17 @@ describe('packages_list_app', () => { }); describe('delete alert handling', () => { - const { location } = window.location; + const originalLocation = window.location.href; const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`; beforeEach(() => { createStore(); jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {}); - delete window.location; - window.location = { - href: `foo_bar_baz${search}`, - search, - }; + setWindowLocation(search); }); afterEach(() => { - window.location = location; + setWindowLocation(originalLocation); }); it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => { @@ -262,11 +259,11 @@ describe('packages_list_app', () => { it('calls historyReplaceState with a clean url', () => { mountComponent(); - expect(commonUtils.historyReplaceState).toHaveBeenCalledWith('foo_bar_baz'); + expect(commonUtils.historyReplaceState).toHaveBeenCalledWith(originalLocation); }); it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => { - window.location.search = ''; + setWindowLocation('?'); mountComponent(); expect(createFlash).not.toHaveBeenCalled(); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js index 2b3acbf99f3..0504a42dfcf 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js @@ -1,17 +1,35 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { mavenPackage, conanPackage, nugetPackage, npmPackage } from 'jest/packages/mock_data'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + conanMetadata, + mavenMetadata, + nugetMetadata, + packageData, +} from 'jest/packages_and_registries/package_registry/mock_data'; import component from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; +import { + PACKAGE_TYPE_NUGET, + PACKAGE_TYPE_CONAN, + PACKAGE_TYPE_MAVEN, + PACKAGE_TYPE_NPM, +} from '~/packages_and_registries/package_registry/constants'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; +const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() }; +const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() }; +const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; +const npmPackage = { packageType: PACKAGE_TYPE_NPM, metadata: {} }; + describe('Package Additional Metadata', () => { let wrapper; const defaultProps = { - packageEntity: { ...mavenPackage }, + packageEntity: { + ...packageData(mavenPackage), + }, }; const mountComponent = (props) => { - wrapper = shallowMount(component, { + wrapper = shallowMountExtended(component, { propsData: { ...defaultProps, ...props }, stubs: { DetailsRow, @@ -25,14 +43,14 @@ describe('Package Additional Metadata', () => { wrapper = null; }); - const findTitle = () => wrapper.find('[data-testid="title"]'); - const findMainArea = () => wrapper.find('[data-testid="main"]'); - const findNugetSource = () => wrapper.find('[data-testid="nuget-source"]'); - const findNugetLicense = () => wrapper.find('[data-testid="nuget-license"]'); - const findConanRecipe = () => wrapper.find('[data-testid="conan-recipe"]'); - const findMavenApp = () => wrapper.find('[data-testid="maven-app"]'); - const findMavenGroup = () => wrapper.find('[data-testid="maven-group"]'); - const findElementLink = (container) => container.find(GlLink); + const findTitle = () => wrapper.findByTestId('title'); + const findMainArea = () => wrapper.findByTestId('main'); + const findNugetSource = () => wrapper.findByTestId('nuget-source'); + const findNugetLicense = () => wrapper.findByTestId('nuget-license'); + const findConanRecipe = () => wrapper.findByTestId('conan-recipe'); + const findMavenApp = () => wrapper.findByTestId('maven-app'); + const findMavenGroup = () => wrapper.findByTestId('maven-group'); + const findElementLink = (container) => container.findComponent(GlLink); it('has the correct title', () => { mountComponent(); @@ -43,27 +61,21 @@ describe('Package Additional Metadata', () => { expect(title.text()).toBe('Additional Metadata'); }); - describe.each` - packageEntity | visible | metadata - ${mavenPackage} | ${true} | ${'maven_metadatum'} - ${conanPackage} | ${true} | ${'conan_metadatum'} - ${nugetPackage} | ${true} | ${'nuget_metadatum'} - ${npmPackage} | ${false} | ${null} - `('Component visibility', ({ packageEntity, visible, metadata }) => { - it(`Is ${visible} that the component markup is visible when the package is ${packageEntity.package_type}`, () => { + it.each` + packageEntity | visible | packageType + ${mavenPackage} | ${true} | ${PACKAGE_TYPE_MAVEN} + ${conanPackage} | ${true} | ${PACKAGE_TYPE_CONAN} + ${nugetPackage} | ${true} | ${PACKAGE_TYPE_NUGET} + ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM} + `( + `It is $visible that the component is visible when the package is $packageType`, + ({ packageEntity, visible }) => { mountComponent({ packageEntity }); expect(findTitle().exists()).toBe(visible); expect(findMainArea().exists()).toBe(visible); - }); - - it(`The component is hidden if ${metadata} is missing`, () => { - mountComponent({ packageEntity: { ...packageEntity, [metadata]: null } }); - - expect(findTitle().exists()).toBe(false); - expect(findMainArea().exists()).toBe(false); - }); - }); + }, + ); describe('nuget metadata', () => { beforeEach(() => { @@ -71,15 +83,15 @@ describe('Package Additional Metadata', () => { }); it.each` - name | finderFunction | text | link | icon - ${'source'} | ${findNugetSource} | ${'Source project located at project-foo-url'} | ${'project_url'} | ${'project'} - ${'license'} | ${findNugetLicense} | ${'License information located at license-foo-url'} | ${'license_url'} | ${'license'} + name | finderFunction | text | link | icon + ${'source'} | ${findNugetSource} | ${'Source project located at projectUrl'} | ${'projectUrl'} | ${'project'} + ${'license'} | ${findNugetLicense} | ${'License information located at licenseUrl'} | ${'licenseUrl'} | ${'license'} `('$name element', ({ finderFunction, text, link, icon }) => { const element = finderFunction(); expect(element.exists()).toBe(true); expect(element.text()).toBe(text); expect(element.props('icon')).toBe(icon); - expect(findElementLink(element).attributes('href')).toBe(nugetPackage.nuget_metadatum[link]); + expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]); }); }); @@ -89,8 +101,8 @@ describe('Package Additional Metadata', () => { }); it.each` - name | finderFunction | text | icon - ${'recipe'} | ${findConanRecipe} | ${'Recipe: conan-package/1.0.0@conan+conan-package/stable'} | ${'information-o'} + name | finderFunction | text | icon + ${'recipe'} | ${findConanRecipe} | ${'Recipe: package-8/1.0.0@gitlab-org+gitlab-test/stable'} | ${'information-o'} `('$name element', ({ finderFunction, text, icon }) => { const element = finderFunction(); expect(element.exists()).toBe(true); @@ -105,9 +117,9 @@ describe('Package Additional Metadata', () => { }); it.each` - name | finderFunction | text | icon - ${'app'} | ${findMavenApp} | ${'App name: test-app'} | ${'information-o'} - ${'group'} | ${findMavenGroup} | ${'App group: com.test.app'} | ${'information-o'} + name | finderFunction | text | icon + ${'app'} | ${findMavenApp} | ${'App name: appName'} | ${'information-o'} + ${'group'} | ${findMavenGroup} | ${'App group: appGroup'} | ${'information-o'} `('$name element', ({ finderFunction, text, icon }) => { const element = finderFunction(); expect(element.exists()).toBe(true); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js index 4be4733b3b0..e6d12f5e479 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js @@ -5,6 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue'; import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; @@ -48,6 +49,7 @@ describe('PackagesApp', () => { const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findPackageTitle = () => wrapper.findComponent(PackageTitle); const findPackageHistory = () => wrapper.findComponent(PackageHistory); + const findAdditionalMetadata = () => wrapper.findComponent(AdditionalMetadata); afterEach(() => { wrapper.destroy(); @@ -95,4 +97,15 @@ describe('PackagesApp', () => { projectName: provide.projectName, }); }); + + it('renders additional metadata and has the right props', async () => { + createComponent(); + + await waitForPromises(); + + expect(findAdditionalMetadata().exists()).toBe(true); + expect(findAdditionalMetadata().props()).toMatchObject({ + packageEntity: expect.objectContaining(packageData()), + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 9de3dee0738..d1b81aa8b5f 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -58,10 +58,49 @@ export const packageData = (extend) => ({ ...extend, }); +export const conanMetadata = () => ({ + packageChannel: 'stable', + packageUsername: 'gitlab-org+gitlab-test', + recipe: 'package-8/1.0.0@gitlab-org+gitlab-test/stable', + recipePath: 'package-8/1.0.0/gitlab-org+gitlab-test/stable', +}); + +export const composerMetadata = () => ({ + targetSha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', + composerJson: { + license: 'MIT', + version: '1.0.0', + }, +}); + +export const pypyMetadata = () => ({ + requiredPython: '1.0.0', +}); + +export const mavenMetadata = () => ({ + appName: 'appName', + appGroup: 'appGroup', + appVersion: 'appVersion', + path: 'path', +}); + +export const nugetMetadata = () => ({ + iconUrl: 'iconUrl', + licenseUrl: 'licenseUrl', + projectUrl: 'projectUrl', +}); + export const packageDetailsQuery = () => ({ data: { package: { ...packageData(), + metadata: { + ...conanMetadata(), + ...composerMetadata(), + ...pypyMetadata(), + ...mavenMetadata(), + ...nugetMetadata(), + }, tags: { nodes: packageTags(), __typename: 'PackageTagConnection', diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js index 1e51ddf909a..1db255106ed 100644 --- a/spec/frontend/persistent_user_callout_spec.js +++ b/spec/frontend/persistent_user_callout_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -166,6 +167,8 @@ describe('PersistentUserCallout', () => { let mockAxios; let persistentUserCallout; + useMockLocationHelper(); + beforeEach(() => { const fixture = createFollowLinkFixture(); const container = fixture.querySelector('.container'); @@ -174,9 +177,6 @@ describe('PersistentUserCallout', () => { persistentUserCallout = new PersistentUserCallout(container); jest.spyOn(persistentUserCallout.container, 'remove').mockImplementation(() => {}); - - delete window.location; - window.location = { assign: jest.fn() }; }); afterEach(() => { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index b0d1a69ee56..0c5c08d7190 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -2,6 +2,7 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; @@ -348,15 +349,14 @@ describe('Pipeline editor app component', () => { }); describe('when a template parameter is present in the URL', () => { - const { location } = window; + const originalLocation = window.location.href; beforeEach(() => { - delete window.location; - window.location = new URL('https://localhost?template=Android'); + setWindowLocation('?template=Android'); }); afterEach(() => { - window.location = location; + setWindowLocation(originalLocation); }); it('renders the given template', async () => { diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js index f1172a73d36..4d2dcf83d3b 100644 --- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js +++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js @@ -1,6 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import createFlash from '~/flash'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; @@ -19,6 +20,8 @@ import { jest.mock('~/flash'); const expectedUrl = '/foo'; +useMockLocationHelper(); + describe('ProfilePreferences component', () => { let wrapper; const defaultProvide = { @@ -174,8 +177,6 @@ describe('ProfilePreferences component', () => { }); describe('theme changes', () => { - const { location } = window; - let themeInput; let form; @@ -197,18 +198,6 @@ describe('ProfilePreferences component', () => { form.dispatchEvent(successEvent); } - beforeAll(() => { - delete window.location; - window.location = { - ...location, - reload: jest.fn(), - }; - }); - - afterAll(() => { - window.location = location; - }); - beforeEach(() => { setupBody(); themeInput = createThemeInput(); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 585614a6b79..e77bae79f68 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -1,12 +1,15 @@ import { GlButton, GlModal } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; import SnippetHeader from '~/snippets/components/snippet_header.vue'; import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; +useMockLocationHelper(); + describe('Snippet header component', () => { let wrapper; let snippet; @@ -200,19 +203,6 @@ describe('Snippet header component', () => { }); describe('Delete mutation', () => { - const { location } = window; - - beforeEach(() => { - delete window.location; - window.location = { - pathname: '', - }; - }); - - afterEach(() => { - window.location = location; - }); - it('dispatches a mutation to delete the snippet with correct variables', () => { createComponent(); wrapper.vm.deleteSnippet(); diff --git a/spec/lib/gitlab/pagination/keyset/order_spec.rb b/spec/lib/gitlab/pagination/keyset/order_spec.rb index a2179b4e902..b867dd533e0 100644 --- a/spec/lib/gitlab/pagination/keyset/order_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/order_spec.rb @@ -6,32 +6,67 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do describe 'paginate over items correctly' do let(:table) { Arel::Table.new(:my_table) } let(:order) { nil } + let(:default_limit) { 999 } + let(:query_building_method) { :build_query } def run_query(query) ApplicationRecord.connection.execute(query).to_a end - def build_query(order:, where_conditions: nil, limit: nil) + def where_conditions_as_sql(where_conditions) + "WHERE #{Array(where_conditions).map(&:to_sql).join(' OR ')}" + end + + def build_query(order:, where_conditions: [], limit: nil) + where_string = where_conditions_as_sql(where_conditions) + + <<-SQL + SELECT id, year, month + FROM (#{table_data}) my_table (id, year, month) + #{where_string if where_conditions.present?} + ORDER BY #{order} + LIMIT #{limit || default_limit}; + SQL + end + + def build_union_query(order:, where_conditions: [], limit: nil) + return build_query(order: order, where_conditions: where_conditions, limit: limit) if where_conditions.blank? + + union_queries = Array(where_conditions).map do |where_condition| + <<-SQL + (SELECT id, year, month + FROM (#{table_data}) my_table (id, year, month) + WHERE #{where_condition.to_sql} + ORDER BY #{order} + LIMIT #{limit || default_limit}) + SQL + end + + union_query = union_queries.join(" UNION ALL ") + <<-SQL - SELECT id, year, month - FROM (#{table_data}) my_table (id, year, month) - WHERE #{where_conditions || '1=1'} - ORDER BY #{order} - LIMIT #{limit || 999}; + SELECT id, year, month + FROM (#{union_query}) as my_table + ORDER BY #{order} + LIMIT #{limit || default_limit}; SQL end + def cursor_attributes_for_node(node) + order.cursor_attributes_for_node(node) + end + def iterate_and_collect(order:, page_size:, where_conditions: nil) all_items = [] loop do - paginated_items = run_query(build_query(order: order, where_conditions: where_conditions, limit: page_size)) + paginated_items = run_query(send(query_building_method, order: order, where_conditions: where_conditions, limit: page_size)) break if paginated_items.empty? all_items.concat(paginated_items) last_item = paginated_items.last - cursor_attributes = order.cursor_attributes_for_node(last_item) - where_conditions = order.where_values_with_or_query(cursor_attributes).to_sql + cursor_attributes = cursor_attributes_for_node(last_item) + where_conditions = order.build_where_values(cursor_attributes) end all_items @@ -54,15 +89,41 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do it { expect(subject).to eq(expected) } end + + context 'when using the conditions in an UNION query' do + let(:query_building_method) { :build_union_query } + + it { expect(subject).to eq(expected) } + end + + context 'when the cursor attributes are SQL literals' do + def cursor_attributes_for_node(node) + # Simulate the scenario where the cursor attributes are SQL literals + order.cursor_attributes_for_node(node).transform_values.each_with_index do |value, i| + index = i + 1 + value_sql = value.nil? ? 'NULL::integer' : value + values = [value_sql] * index + Arel.sql("(ARRAY[#{values.join(',')}])[#{index}]") # example: ARRAY[cursor_value][1] will return cursor_value + end + end + + it { expect(subject).to eq(expected) } + + context 'when using the conditions in an UNION query' do + let(:query_building_method) { :build_union_query } + + it { expect(subject).to eq(expected) } + end + end end context 'when paginating backwards' do subject do last_item = expected.last cursor_attributes = order.cursor_attributes_for_node(last_item) - where_conditions = order.reversed_order.where_values_with_or_query(cursor_attributes) + where_conditions = order.reversed_order.build_where_values(cursor_attributes) - iterate_and_collect(order: order.reversed_order, page_size: 2, where_conditions: where_conditions.to_sql) + iterate_and_collect(order: order.reversed_order, page_size: 2, where_conditions: where_conditions) end it do @@ -371,7 +432,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do reversed = order.reversed_order before_conditions = reversed.where_values_with_or_query(before_cursor) - query = build_query(order: order, where_conditions: "(#{after_conditions.to_sql}) AND (#{before_conditions.to_sql})", limit: 100) + query = build_query(order: order, where_conditions: [Arel::Nodes::And.new([after_conditions, before_conditions])], limit: 100) expect(run_query(query)).to eq([ { "id" => 2, "year" => 2011, "month" => 0 }, diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml index 822941e7c36..c5bcedd491a 100644 --- a/spec/requests/api/project_attributes.yml +++ b/spec/requests/api/project_attributes.yml @@ -121,7 +121,6 @@ project_feature: - project_id - requirements_access_level - security_and_compliance_access_level - - container_registry_access_level - updated_at computed_attributes: - issues_enabled diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index c2e564b2759..06d27dcb87f 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -207,6 +207,18 @@ RSpec.describe API::Projects do let(:current_user) { user } end + it 'includes container_registry_access_level', :aggregate_failures do + project.project_feature.update!(container_registry_access_level: ProjectFeature::DISABLED) + + get api('/projects', user) + project_response = json_response.find { |p| p['id'] == project.id } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(project_response['container_registry_access_level']).to eq('disabled') + expect(project_response['container_registry_enabled']).to eq(false) + end + context 'when some projects are in a group' do before do create(:project, :public, group: create(:group)) @@ -1042,7 +1054,7 @@ RSpec.describe API::Projects do expect(response).to have_gitlab_http_status(:bad_request) end - it "assigns attributes to project" do + it "assigns attributes to project", :aggregate_failures do project = attributes_for(:project, { path: 'camelCasePath', issues_enabled: false, @@ -1064,6 +1076,7 @@ RSpec.describe API::Projects do }).tap do |attrs| attrs[:operations_access_level] = 'disabled' attrs[:analytics_access_level] = 'disabled' + attrs[:container_registry_access_level] = 'private' end post api('/projects', user), params: project @@ -1071,7 +1084,10 @@ RSpec.describe API::Projects do expect(response).to have_gitlab_http_status(:created) project.each_pair do |k, v| - next if %i[has_external_issue_tracker has_external_wiki issues_enabled merge_requests_enabled wiki_enabled storage_version].include?(k) + next if %i[ + has_external_issue_tracker has_external_wiki issues_enabled merge_requests_enabled wiki_enabled storage_version + container_registry_access_level + ].include?(k) expect(json_response[k.to_s]).to eq(v) end @@ -1083,6 +1099,18 @@ RSpec.describe API::Projects do expect(project.project_feature.wiki_access_level).to eq(ProjectFeature::DISABLED) expect(project.operations_access_level).to eq(ProjectFeature::DISABLED) expect(project.project_feature.analytics_access_level).to eq(ProjectFeature::DISABLED) + expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::PRIVATE) + end + + it 'assigns container_registry_enabled to project', :aggregate_failures do + project = attributes_for(:project, { container_registry_enabled: true }) + + post api('/projects', user), params: project + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['container_registry_enabled']).to eq(true) + expect(json_response['container_registry_access_level']).to eq('enabled') + expect(Project.find_by(path: project[:path]).container_registry_access_level).to eq(ProjectFeature::ENABLED) end it 'creates a project using a template' do @@ -1340,6 +1368,14 @@ RSpec.describe API::Projects do expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id) end + it 'includes container_registry_access_level', :aggregate_failures do + get api("/users/#{user4.id}/projects/", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.first.keys).to include('container_registry_access_level') + end + context 'and using id_after' do let!(:another_public_project) { create(:project, :public, name: 'another_public_project', creator_id: user4.id, namespace: user4.namespace) } @@ -1649,6 +1685,59 @@ RSpec.describe API::Projects do expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_truthy end + + context 'container_registry_enabled' do + using RSpec::Parameterized::TableSyntax + + where(:container_registry_enabled, :container_registry_access_level) do + true | ProjectFeature::ENABLED + false | ProjectFeature::DISABLED + end + + with_them do + it 'setting container_registry_enabled also sets container_registry_access_level', :aggregate_failures do + project_attributes = attributes_for(:project).tap do |attrs| + attrs[:container_registry_enabled] = container_registry_enabled + end + + post api("/projects/user/#{user.id}", admin), params: project_attributes + + project = Project.find_by(path: project_attributes[:path]) + expect(response).to have_gitlab_http_status(:created) + expect(json_response['container_registry_access_level']).to eq(ProjectFeature.str_from_access_level(container_registry_access_level)) + expect(json_response['container_registry_enabled']).to eq(container_registry_enabled) + expect(project.container_registry_access_level).to eq(container_registry_access_level) + expect(project.container_registry_enabled).to eq(container_registry_enabled) + end + end + end + + context 'container_registry_access_level' do + using RSpec::Parameterized::TableSyntax + + where(:container_registry_access_level, :container_registry_enabled) do + 'enabled' | true + 'private' | true + 'disabled' | false + end + + with_them do + it 'setting container_registry_access_level also sets container_registry_enabled', :aggregate_failures do + project_attributes = attributes_for(:project).tap do |attrs| + attrs[:container_registry_access_level] = container_registry_access_level + end + + post api("/projects/user/#{user.id}", admin), params: project_attributes + + project = Project.find_by(path: project_attributes[:path]) + expect(response).to have_gitlab_http_status(:created) + expect(json_response['container_registry_access_level']).to eq(container_registry_access_level) + expect(json_response['container_registry_enabled']).to eq(container_registry_enabled) + expect(project.container_registry_access_level).to eq(ProjectFeature.access_level_from_str(container_registry_access_level)) + expect(project.container_registry_enabled).to eq(container_registry_enabled) + end + end + end end describe "POST /projects/:id/uploads/authorize" do @@ -2034,6 +2123,7 @@ RSpec.describe API::Projects do expect(json_response['jobs_enabled']).to be_present expect(json_response['snippets_enabled']).to be_present expect(json_response['container_registry_enabled']).to be_present + expect(json_response['container_registry_access_level']).to be_present expect(json_response['created_at']).to be_present expect(json_response['last_activity_at']).to be_present expect(json_response['shared_runners_enabled']).to be_present @@ -2125,6 +2215,7 @@ RSpec.describe API::Projects do expect(json_response['resolve_outdated_diff_discussions']).to eq(project.resolve_outdated_diff_discussions) expect(json_response['remove_source_branch_after_merge']).to be_truthy expect(json_response['container_registry_enabled']).to be_present + expect(json_response['container_registry_access_level']).to be_present expect(json_response['created_at']).to be_present expect(json_response['last_activity_at']).to be_present expect(json_response['shared_runners_enabled']).to be_present @@ -2951,6 +3042,14 @@ RSpec.describe API::Projects do end end + it 'sets container_registry_access_level', :aggregate_failures do + put api("/projects/#{project.id}", user), params: { container_registry_access_level: 'private' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['container_registry_access_level']).to eq('private') + expect(Project.find_by(path: project[:path]).container_registry_access_level).to eq(ProjectFeature::PRIVATE) + end + it 'returns 400 when nothing sent' do project_param = {} diff --git a/spec/views/groups/edit.html.haml_spec.rb b/spec/views/groups/edit.html.haml_spec.rb index f40b03fda2a..43e11d31611 100644 --- a/spec/views/groups/edit.html.haml_spec.rb +++ b/spec/views/groups/edit.html.haml_spec.rb @@ -25,7 +25,7 @@ RSpec.describe 'groups/edit.html.haml' do render expect(rendered).to have_content("Prevent sharing a project within #{test_group.name} with other groups") - expect(rendered).to have_css('.js-descr', text: 'help text here') + expect(rendered).to have_content('help text here') expect(rendered).to have_field('group_share_with_group_lock', **checkbox_options) end end diff --git a/spec/views/groups/show.html.haml_spec.rb b/spec/views/groups/show.html.haml_spec.rb index f40b03fda2a..43e11d31611 100644 --- a/spec/views/groups/show.html.haml_spec.rb +++ b/spec/views/groups/show.html.haml_spec.rb @@ -25,7 +25,7 @@ RSpec.describe 'groups/edit.html.haml' do render expect(rendered).to have_content("Prevent sharing a project within #{test_group.name} with other groups") - expect(rendered).to have_css('.js-descr', text: 'help text here') + expect(rendered).to have_content('help text here') expect(rendered).to have_field('group_share_with_group_lock', **checkbox_options) end end |