diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-03 12:10:53 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-03 12:10:53 +0300 |
commit | 74780f24f2005d24a0e0a8fa1b3ae5391ae2928f (patch) | |
tree | 888505246a85ff9e97042b43b18450730586596e | |
parent | ccbe90951fb75b3527eaaad404e6abb6ed09ca8c (diff) |
Add latest changes from gitlab-org/gitlab@master
45 files changed, 1066 insertions, 743 deletions
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index ac4763103b2..ef66c8aeb2e 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -405,6 +405,7 @@ - <<: *if-security-merge-request changes: *code-backstage-patterns - <<: *if-merge-request-title-as-if-foss + - <<: *if-merge-request-title-run-all-rspec - <<: *if-merge-request changes: *ci-patterns @@ -483,6 +484,7 @@ - <<: *if-security-merge-request changes: *code-qa-patterns - <<: *if-merge-request-title-as-if-foss + - <<: *if-merge-request-title-run-all-rspec - <<: *if-merge-request changes: *ci-patterns @@ -700,6 +702,7 @@ changes: *db-patterns - <<: *if-merge-request-title-as-if-foss changes: *db-patterns + - <<: *if-merge-request-title-run-all-rspec - <<: *if-merge-request changes: *ci-patterns @@ -716,6 +719,7 @@ changes: *db-patterns - <<: *if-merge-request-title-as-if-foss changes: *db-patterns + - <<: *if-merge-request-title-run-all-rspec .rails:rules:as-if-foss-unit: rules: @@ -725,6 +729,7 @@ changes: *backend-patterns - <<: *if-merge-request-title-as-if-foss changes: *backend-patterns + - <<: *if-merge-request-title-run-all-rspec - <<: *if-merge-request changes: *ci-patterns @@ -741,6 +746,7 @@ changes: *backend-patterns - <<: *if-merge-request-title-as-if-foss changes: *backend-patterns + - <<: *if-merge-request-title-run-all-rspec .rails:rules:as-if-foss-integration: rules: @@ -750,6 +756,7 @@ changes: *backend-patterns - <<: *if-merge-request-title-as-if-foss changes: *backend-patterns + - <<: *if-merge-request-title-run-all-rspec - <<: *if-merge-request changes: *ci-patterns @@ -766,6 +773,7 @@ changes: *backend-patterns - <<: *if-merge-request-title-as-if-foss changes: *backend-patterns + - <<: *if-merge-request-title-run-all-rspec .rails:rules:as-if-foss-system: rules: @@ -775,6 +783,7 @@ changes: *code-backstage-patterns - <<: *if-merge-request-title-as-if-foss changes: *code-backstage-patterns + - <<: *if-merge-request-title-run-all-rspec - <<: *if-merge-request changes: *ci-patterns @@ -791,6 +800,7 @@ changes: *code-backstage-patterns - <<: *if-merge-request-title-as-if-foss changes: *code-backstage-patterns + - <<: *if-merge-request-title-run-all-rspec .rails:rules:ee-and-foss-db-library-code: rules: diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue new file mode 100644 index 00000000000..a59a8afa4de --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -0,0 +1,304 @@ +<script> +import { + GlIcon, + GlLink, + GlForm, + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlButton, + GlFormRadio, + GlFormRadioGroup, + GlFormSelect, +} from '@gitlab/ui'; +import { buildApiUrl } from '~/api/api_utils'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import csrf from '~/lib/utils/csrf'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; + +const PRIVATE_VISIBILITY = 'private'; +const INTERNAL_VISIBILITY = 'internal'; +const PUBLIC_VISIBILITY = 'public'; + +const ALLOWED_VISIBILITY = { + private: [PRIVATE_VISIBILITY], + internal: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY], + public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY], +}; + +export default { + components: { + GlForm, + GlIcon, + GlLink, + GlButton, + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlFormTextarea, + GlFormGroup, + GlFormRadio, + GlFormRadioGroup, + GlFormSelect, + }, + props: { + endpoint: { + type: String, + required: true, + }, + newGroupPath: { + type: String, + required: true, + }, + projectFullPath: { + type: String, + required: true, + }, + visibilityHelpPath: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + projectName: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectDescription: { + type: String, + required: true, + }, + projectVisibility: { + type: String, + required: true, + }, + }, + data() { + return { + isSaving: false, + namespaces: [], + selectedNamespace: {}, + fork: { + name: this.projectName, + slug: this.projectPath, + description: this.projectDescription, + visibility: this.projectVisibility, + }, + }; + }, + computed: { + projectUrl() { + return `${gon.gitlab_url}/`; + }, + projectAllowedVisibility() { + return ALLOWED_VISIBILITY[this.projectVisibility]; + }, + namespaceAllowedVisibility() { + return ( + ALLOWED_VISIBILITY[this.selectedNamespace.visibility] || + ALLOWED_VISIBILITY[PUBLIC_VISIBILITY] + ); + }, + visibilityLevels() { + return [ + { + text: s__('ForkProject|Private'), + value: PRIVATE_VISIBILITY, + icon: 'lock', + help: s__('ForkProject|The project can be accessed without any authentication.'), + disabled: this.isVisibilityLevelDisabled(PRIVATE_VISIBILITY), + }, + { + text: s__('ForkProject|Internal'), + value: INTERNAL_VISIBILITY, + icon: 'shield', + help: s__('ForkProject|The project can be accessed by any logged in user.'), + disabled: this.isVisibilityLevelDisabled(INTERNAL_VISIBILITY), + }, + { + text: s__('ForkProject|Public'), + value: PUBLIC_VISIBILITY, + icon: 'earth', + help: s__( + 'ForkProject|Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', + ), + disabled: this.isVisibilityLevelDisabled(PUBLIC_VISIBILITY), + }, + ]; + }, + }, + watch: { + selectedNamespace(newVal) { + const { visibility } = newVal; + + if (this.projectAllowedVisibility.includes(visibility)) { + this.fork.visibility = visibility; + } + }, + }, + mounted() { + this.fetchNamespaces(); + }, + methods: { + async fetchNamespaces() { + const { data } = await axios.get(this.endpoint); + this.namespaces = data.namespaces; + }, + isVisibilityLevelDisabled(visibilityLevel) { + return !( + this.projectAllowedVisibility.includes(visibilityLevel) && + this.namespaceAllowedVisibility.includes(visibilityLevel) + ); + }, + async onSubmit() { + this.isSaving = true; + + const { projectId } = this; + const { name, slug, description, visibility } = this.fork; + const { id: namespaceId } = this.selectedNamespace; + + const postParams = { + id: projectId, + name, + namespace_id: namespaceId, + path: slug, + description, + visibility, + }; + + const forkProjectPath = `/api/:version/projects/:id/fork`; + const url = buildApiUrl(forkProjectPath).replace(':id', encodeURIComponent(this.projectId)); + + try { + const { data } = await axios.post(url, postParams); + redirectTo(data.web_url); + return; + } catch (error) { + createFlash({ message: error }); + } + }, + }, + csrf, +}; +</script> + +<template> + <gl-form method="POST" @submit.prevent="onSubmit"> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + + <gl-form-group label="Project name" label-for="fork-name"> + <gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required /> + </gl-form-group> + + <div class="gl-display-flex"> + <div class="gl-w-half"> + <gl-form-group label="Project URL" label-for="fork-url" class="gl-pr-2"> + <gl-form-input-group> + <template #prepend> + <gl-input-group-text> + {{ projectUrl }} + </gl-input-group-text> + </template> + <gl-form-select + id="fork-url" + v-model="selectedNamespace" + data-testid="fork-url-input" + required + > + <template slot="first"> + <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option> + </template> + <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace"> + {{ namespace.name }} + </option> + </gl-form-select> + </gl-form-input-group> + </gl-form-group> + </div> + <div class="gl-w-half"> + <gl-form-group label="Project slug" label-for="fork-slug" class="gl-pl-2"> + <gl-form-input + id="fork-slug" + v-model="fork.slug" + data-testid="fork-slug-input" + required + /> + </gl-form-group> + </div> + </div> + + <p class="gl-mt-n5 gl-text-gray-500"> + {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }} + <gl-link :href="newGroupPath" target="_blank"> + {{ s__('ForkProject|Create a group') }} + </gl-link> + </p> + + <gl-form-group label="Project description (optional)" label-for="fork-description"> + <gl-form-textarea + id="fork-description" + v-model="fork.description" + data-testid="fork-description-textarea" + /> + </gl-form-group> + + <gl-form-group> + <label> + {{ s__('ForkProject|Visibility level') }} + <gl-link :href="visibilityHelpPath" target="_blank"> + <gl-icon name="question-o" /> + </gl-link> + </label> + <gl-form-radio-group + v-model="fork.visibility" + data-testid="fork-visibility-radio-group" + required + > + <gl-form-radio + v-for="{ text, value, icon, help, disabled } in visibilityLevels" + :key="value" + :value="value" + :disabled="disabled" + :data-testid="`radio-${value}`" + > + <div> + <gl-icon :name="icon" /> + <span>{{ text }}</span> + </div> + <template #help>{{ help }}</template> + </gl-form-radio> + </gl-form-radio-group> + </gl-form-group> + + <div class="gl-display-flex gl-justify-content-space-between gl-mt-8"> + <gl-button + type="submit" + category="primary" + variant="confirm" + data-testid="submit-button" + :loading="isSaving" + > + {{ s__('ForkProject|Fork project') }} + </gl-button> + <gl-button + type="reset" + class="gl-mr-3" + data-testid="cancel-button" + :disabled="isSaving" + :href="projectFullPath" + > + {{ s__('ForkProject|Cancel') }} + </gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index 26353aefe82..420639eefb7 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -1,16 +1,53 @@ import Vue from 'vue'; +import ForkForm from './components/fork_form.vue'; import ForkGroupsList from './components/fork_groups_list.vue'; const mountElement = document.getElementById('fork-groups-mount-element'); -const { endpoint } = mountElement.dataset; -// eslint-disable-next-line no-new -new Vue({ - el: mountElement, - render(h) { - return h(ForkGroupsList, { - props: { - endpoint, - }, - }); - }, -}); + +if (gon.features.forkProjectForm) { + const { + endpoint, + newGroupPath, + projectFullPath, + visibilityHelpPath, + projectId, + projectName, + projectPath, + projectDescription, + projectVisibility, + } = mountElement.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: mountElement, + render(h) { + return h(ForkForm, { + props: { + endpoint, + newGroupPath, + projectFullPath, + visibilityHelpPath, + projectId, + projectName, + projectPath, + projectDescription, + projectVisibility, + }, + }); + }, + }); +} else { + const { endpoint } = mountElement.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: mountElement, + render(h) { + return h(ForkGroupsList, { + props: { + endpoint, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 70167bde188..28d7dec85f4 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -5,7 +5,6 @@ import { GlDropdownSectionHeader, GlDropdownItem, GlIcon, - GlModalDirective, } from '@gitlab/ui'; import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; @@ -13,15 +12,12 @@ import { __ } from '../../locale'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import projectShortPathQuery from '../queries/project_short_path.query.graphql'; -import UploadBlobModal from './upload_blob_modal.vue'; const ROW_TYPES = { header: 'header', divider: 'divider', }; -const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob'; - export default { components: { GlDropdown, @@ -29,7 +25,6 @@ export default { GlDropdownSectionHeader, GlDropdownItem, GlIcon, - UploadBlobModal, }, apollo: { projectShortPath: { @@ -51,9 +46,6 @@ export default { }, }, }, - directives: { - GlModal: GlModalDirective, - }, mixins: [getRefMixin], props: { currentPath: { @@ -71,21 +63,6 @@ export default { required: false, default: false, }, - canPushCode: { - type: Boolean, - required: false, - default: false, - }, - selectedBranch: { - type: String, - required: false, - default: '', - }, - origionalBranch: { - type: String, - required: false, - default: '', - }, newBranchPath: { type: String, required: false, @@ -116,13 +93,7 @@ export default { required: false, default: null, }, - uploadPath: { - type: String, - required: false, - default: '', - }, }, - uploadBlobModalId: UPLOAD_BLOB_MODAL_ID, data() { return { projectShortPath: '', @@ -155,10 +126,7 @@ export default { ); }, canCreateMrFromFork() { - return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn; - }, - showUploadModal() { - return this.canEditTree && !this.$apollo.queries.userPermissions.loading; + return this.userPermissions.forkProject && this.userPermissions.createMergeRequestIn; }, dropdownItems() { const items = []; @@ -181,9 +149,10 @@ export default { { attrs: { href: '#modal-upload-blob', + 'data-target': '#modal-upload-blob', + 'data-toggle': 'modal', }, text: __('Upload file'), - modalId: UPLOAD_BLOB_MODAL_ID, }, { attrs: { @@ -284,26 +253,12 @@ export default { <gl-icon name="chevron-down" :size="16" class="float-left" /> </template> <template v-for="(item, i) in dropdownItems"> - <component - :is="getComponent(item.type)" - :key="i" - v-bind="item.attrs" - v-gl-modal="item.modalId || null" - > + <component :is="getComponent(item.type)" :key="i" v-bind="item.attrs"> {{ item.text }} </component> </template> </gl-dropdown> </li> </ol> - <upload-blob-modal - v-if="showUploadModal" - :modal-id="$options.uploadBlobModalId" - :commit-message="__('Upload New File')" - :target-branch="selectedBranch" - :origional-branch="origionalBranch" - :can-push-code="canPushCode" - :path="uploadPath" - /> </nav> </template> diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue deleted file mode 100644 index 4cdfc5e947a..00000000000 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ /dev/null @@ -1,218 +0,0 @@ -<script> -import { - GlModal, - GlForm, - GlFormGroup, - GlFormInput, - GlFormTextarea, - GlToggle, - GlButton, - GlAlert, -} from '@gitlab/ui'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { ContentTypeMultipartFormData } from '~/lib/utils/headers'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; - -const PRIMARY_OPTIONS_TEXT = __('Upload file'); -const SECONDARY_OPTIONS_TEXT = __('Cancel'); -const MODAL_TITLE = __('Upload New File'); -const COMMIT_LABEL = __('Commit message'); -const TARGET_BRANCH_LABEL = __('Target branch'); -const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); -const REMOVE_FILE_TEXT = __('Remove file'); -const NEW_BRANCH_IN_FORK = __( - 'A new branch will be created in your fork and a new merge request will be started.', -); -const ERROR_MESSAGE = __('Error uploading file. Please try again.'); - -export default { - components: { - GlModal, - GlForm, - GlFormGroup, - GlFormInput, - GlFormTextarea, - GlToggle, - GlButton, - UploadDropzone, - GlAlert, - }, - i18n: { - MODAL_TITLE, - COMMIT_LABEL, - TARGET_BRANCH_LABEL, - TOGGLE_CREATE_MR_LABEL, - REMOVE_FILE_TEXT, - NEW_BRANCH_IN_FORK, - }, - props: { - modalId: { - type: String, - required: true, - }, - commitMessage: { - type: String, - required: true, - }, - targetBranch: { - type: String, - required: true, - }, - origionalBranch: { - type: String, - required: true, - }, - canPushCode: { - type: Boolean, - required: true, - }, - path: { - type: String, - required: true, - }, - }, - data() { - return { - commit: this.commitMessage, - target: this.targetBranch, - createNewMr: true, - file: null, - filePreviewURL: null, - fileBinary: null, - loading: false, - }; - }, - computed: { - primaryOptions() { - return { - text: PRIMARY_OPTIONS_TEXT, - attributes: [ - { - variant: 'success', - loading: this.loading, - disabled: !this.formCompleted || this.loading, - }, - ], - }; - }, - cancelOptions() { - return { - text: SECONDARY_OPTIONS_TEXT, - attributes: [ - { - disabled: this.loading, - }, - ], - }; - }, - formattedFileSize() { - return numberToHumanSize(this.file.size); - }, - showCreateNewMrToggle() { - return this.canPushCode && this.target !== this.origionalBranch; - }, - formCompleted() { - return this.file && this.commit && this.target; - }, - }, - methods: { - setFile(file) { - this.file = file; - - const fileUurlReader = new FileReader(); - - fileUurlReader.readAsDataURL(this.file); - - fileUurlReader.onload = (e) => { - this.filePreviewURL = e.target?.result; - }; - }, - removeFile() { - this.file = null; - this.filePreviewURL = null; - }, - uploadFile() { - this.loading = true; - - const { - $route: { - params: { path }, - }, - } = this; - const uploadPath = joinPaths(this.path, path); - - const formData = new FormData(); - formData.append('branch_name', this.target); - formData.append('create_merge_request', this.createNewMr); - formData.append('commit_message', this.commit); - formData.append('file', this.file); - - return axios - .post(uploadPath, formData, { - headers: { - ...ContentTypeMultipartFormData, - }, - }) - .then((response) => { - visitUrl(response.data.filePath); - }) - .catch(() => { - this.loading = false; - createFlash(ERROR_MESSAGE); - }); - }, - }, -}; -</script> -<template> - <gl-form> - <gl-modal - :modal-id="modalId" - :title="$options.i18n.MODAL_TITLE" - :action-primary="primaryOptions" - :action-cancel="cancelOptions" - @primary.prevent="uploadFile" - > - <upload-dropzone class="gl-h-200! gl-mb-4" single-file-selection @change="setFile"> - <div - v-if="file" - class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" - > - <img v-if="filePreviewURL" :src="filePreviewURL" class="gl-h-11" /> - <div>{{ formattedFileSize }}</div> - <div>{{ file.name }}</div> - <gl-button - category="tertiary" - variant="confirm" - :disabled="loading" - @click="removeFile" - >{{ $options.i18n.REMOVE_FILE_TEXT }}</gl-button - > - </div> - </upload-dropzone> - <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message"> - <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" /> - </gl-form-group> - <gl-form-group - v-if="canPushCode" - :label="$options.i18n.TARGET_BRANCH_LABEL" - label-for="branch_name" - > - <gl-form-input v-model="target" :disabled="loading" name="branch_name" /> - </gl-form-group> - <gl-toggle - v-if="showCreateNewMrToggle" - v-model="createNewMr" - :disabled="loading" - :label="$options.i18n.TOGGLE_CREATE_MR_LABEL" - /> - <gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3"> - {{ $options.i18n.NEW_BRANCH_IN_FORK }} - </gl-alert> - </gl-modal> - </gl-form> -</template> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index fafc6e8eb89..747b85f5c1c 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,8 +1,8 @@ import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { escapeFileUrl } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; +import { parseBoolean } from '../lib/utils/common_utils'; +import { escapeFileUrl } from '../lib/utils/url_utility'; +import { __ } from '../locale'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; @@ -55,8 +55,6 @@ export default function setupVueRepositoryList() { const { canCollaborate, canEditTree, - canPushCode, - selectedBranch, newBranchPath, newTagPath, newBlobPath, @@ -67,7 +65,8 @@ export default function setupVueRepositoryList() { newDirPath, } = breadcrumbEl.dataset; - router.afterEach(({ params: { path } }) => { + router.afterEach(({ params: { path = '/' } }) => { + updateFormAction('.js-upload-blob-form', uploadPath, path); updateFormAction('.js-create-dir-form', newDirPath, path); }); @@ -82,16 +81,12 @@ export default function setupVueRepositoryList() { currentPath: this.$route.params.path, canCollaborate: parseBoolean(canCollaborate), canEditTree: parseBoolean(canEditTree), - canPushCode: parseBoolean(canPushCode), - origionalBranch: ref, - selectedBranch, newBranchPath, newTagPath, newBlobPath, forkNewBlobPath, forkNewDirectoryPath, forkUploadBlobPath, - uploadPath, }, }); }, diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index 1cca42f94b2..e0543c5683b 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -37,6 +37,10 @@ } } +[data-page$='epic_boards:show'] .filter-form { + display: none; +} + .boards-app { @include media-breakpoint-up(sm) { transition: width $sidebar-transition-duration; diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 5576d5766c7..33f046f414f 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -16,6 +16,10 @@ class Projects::ForksController < Projects::ApplicationController feature_category :source_code_management + before_action do + push_frontend_feature_flag(:fork_project_form) + end + def index @total_forks_count = project.forks.size @public_forks_count = project.forks.public_only.size diff --git a/app/graphql/types/todo_action_enum.rb b/app/graphql/types/todo_action_enum.rb index 0e538838474..ef43b6eb464 100644 --- a/app/graphql/types/todo_action_enum.rb +++ b/app/graphql/types/todo_action_enum.rb @@ -2,12 +2,14 @@ module Types class TodoActionEnum < BaseEnum - value 'assigned', value: 1 - value 'mentioned', value: 2 - value 'build_failed', value: 3 - value 'marked', value: 4 - value 'approval_required', value: 5 - value 'unmergeable', value: 6 - value 'directly_addressed', value: 7 + value 'assigned', value: 1, description: 'User was assigned.' + value 'mentioned', value: 2, description: 'User was mentioned.' + value 'build_failed', value: 3, description: 'Build triggered by the user failed.' + value 'marked', value: 4, description: 'User added a TODO.' + value 'approval_required', value: 5, description: 'User was set as an approver.' + value 'unmergeable', value: 6, description: 'Merge request authored by the user could not be merged.' + value 'directly_addressed', value: 7, description: 'User was directly addressed.' + value 'merge_train_removed', value: 8, description: 'Merge request authored by the user was removed from the merge train.' + value 'review_requested', value: 9, description: 'Review was requested from the user.' end end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index b795851ba30..b050f533d77 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -131,8 +131,6 @@ module TreeHelper def breadcrumb_data_attributes attrs = { - selected_branch: selected_branch, - can_push_code: can?(current_user, :push_code, @project).to_s, can_collaborate: can_collaborate_with_project?(@project).to_s, new_blob_path: project_new_blob_path(@project, @ref), upload_path: project_create_blob_path(@project, @ref), diff --git a/app/views/dashboard/projects/_projects.html.haml b/app/views/dashboard/projects/_projects.html.haml index 5122164dbcb..06ef89e0b57 100644 --- a/app/views/dashboard/projects/_projects.html.haml +++ b/app/views/dashboard/projects/_projects.html.haml @@ -1 +1 @@ -= render 'shared/projects/list', projects: @projects, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true), user: current_user += render 'shared/projects/list', projects: @projects, pipeline_status: true, user: current_user diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 0369ee50c40..30d885964b5 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -21,4 +21,5 @@ #js-tree-list{ data: vue_file_list_data(project, ref) } - if can_edit_tree? + = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post = render 'projects/blob/new_dir' diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index ccef28a2cf3..562aa0fd353 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -9,10 +9,20 @@ %br = _('Forking a repository allows you to make changes without affecting the original project.') .col-lg-9 - - if @own_namespace.present? - .fork-thumbnail-container.js-fork-content - %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3 - = _("Select a namespace to fork the project") - = render 'fork_button', namespace: @own_namespace - #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json) } } - + - if Feature.enabled?(:fork_project_form) + #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json), + new_group_path: new_group_path, + project_full_path: project_path(@project), + visibility_help_path: help_page_path("public_access/public_access"), + project_id: @project.id, + project_name: @project.name, + project_path: @project.path, + project_description: @project.description, + project_visibility: @project.visibility } } + - else + - if @own_namespace.present? + .fork-thumbnail-container.js-fork-content + %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3 + = _("Select a namespace to fork the project") + = render 'fork_button', namespace: @own_namespace + #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json) } } diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index 41159df1435..7f5acbbe890 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -40,5 +40,5 @@ - if runner.tags.present? %p - runner.tags.map(&:name).sort.each do |tag| - %span.badge.badge-primary + %span.badge.gl-badge.sm.badge-pill.badge-primary = tag diff --git a/changelogs/unreleased/209061-remove-dashboard-pipeline-status-ff.yml b/changelogs/unreleased/209061-remove-dashboard-pipeline-status-ff.yml new file mode 100644 index 00000000000..5abe0575a0f --- /dev/null +++ b/changelogs/unreleased/209061-remove-dashboard-pipeline-status-ff.yml @@ -0,0 +1,5 @@ +--- +title: Remove dashboard_pipeline_status feature flag +merge_request: 55472 +author: +type: other diff --git a/changelogs/unreleased/254280-replace-bootstrap-modal-trigger-in-app-assets-javascripts-blob-blo.yml b/changelogs/unreleased/254280-replace-bootstrap-modal-trigger-in-app-assets-javascripts-blob-blo.yml deleted file mode 100644 index efb9ccf59a4..00000000000 --- a/changelogs/unreleased/254280-replace-bootstrap-modal-trigger-in-app-assets-javascripts-blob-blo.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Migrate bootstrap modal to GlModal for repo single file uploads -merge_request: 53623 -author: -type: changed diff --git a/changelogs/unreleased/gl-badge-runners.yml b/changelogs/unreleased/gl-badge-runners.yml new file mode 100644 index 00000000000..9b572401264 --- /dev/null +++ b/changelogs/unreleased/gl-badge-runners.yml @@ -0,0 +1,5 @@ +--- +title: Apply new GitLab UI for badge in runners list +merge_request: 54766 +author: Yogi (@yo) +type: changed diff --git a/changelogs/unreleased/sfang-do-not-show-token-name.yml b/changelogs/unreleased/sfang-do-not-show-token-name.yml new file mode 100644 index 00000000000..d4c337a8704 --- /dev/null +++ b/changelogs/unreleased/sfang-do-not-show-token-name.yml @@ -0,0 +1,5 @@ +--- +title: Do not expose user name if user is project bot +merge_request: 54022 +author: +type: changed diff --git a/config/feature_flags/development/dashboard_pipeline_status.yml b/config/feature_flags/development/fork_project_form.yml index f24ba5983a8..93bccc4f41b 100644 --- a/config/feature_flags/development/dashboard_pipeline_status.yml +++ b/config/feature_flags/development/fork_project_form.yml @@ -1,8 +1,8 @@ --- -name: dashboard_pipeline_status -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22029 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/209061 -milestone: '12.7' +name: fork_project_form +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53544 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/321387 +milestone: '13.10' type: development -group: group::continuous integration -default_enabled: true +group: group::source code +default_enabled: false diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index d995b57f275..8fbd425d118 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -5858,13 +5858,15 @@ State of a test report. | Value | Description | | ----- | ----------- | -| `approval_required` | | -| `assigned` | | -| `build_failed` | | -| `directly_addressed` | | -| `marked` | | -| `mentioned` | | -| `unmergeable` | | +| `approval_required` | User was set as an approver. | +| `assigned` | User was assigned. | +| `build_failed` | Build triggered by the user failed. | +| `directly_addressed` | User was directly addressed. | +| `marked` | User added a TODO. | +| `mentioned` | User was mentioned. | +| `merge_train_removed` | Merge request authored by the user was removed from the merge train. | +| `review_requested` | Review was requested from the user. | +| `unmergeable` | Merge request authored by the user could not be merged. | ### TodoStateEnum diff --git a/lib/api/entities/user_safe.rb b/lib/api/entities/user_safe.rb index feb01767fd6..2b7c14cba6e 100644 --- a/lib/api/entities/user_safe.rb +++ b/lib/api/entities/user_safe.rb @@ -3,7 +3,8 @@ module API module Entities class UserSafe < Grape::Entity - expose :id, :name, :username + expose :id, :username + expose :name, unless: ->(user) { user.project_bot? && !options[:current_user].admin?} end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bc98ce39dd7..4c4f25cf7a4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -12036,9 +12036,6 @@ msgstr "" msgid "Error uploading file" msgstr "" -msgid "Error uploading file. Please try again." -msgstr "" - msgid "Error uploading file: %{stripped}" msgstr "" @@ -13262,6 +13259,42 @@ msgstr "" msgid "Fork project?" msgstr "" +msgid "ForkProject|Cancel" +msgstr "" + +msgid "ForkProject|Create a group" +msgstr "" + +msgid "ForkProject|Fork project" +msgstr "" + +msgid "ForkProject|Internal" +msgstr "" + +msgid "ForkProject|Private" +msgstr "" + +msgid "ForkProject|Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group." +msgstr "" + +msgid "ForkProject|Public" +msgstr "" + +msgid "ForkProject|Select a namespace" +msgstr "" + +msgid "ForkProject|The project can be accessed by any logged in user." +msgstr "" + +msgid "ForkProject|The project can be accessed without any authentication." +msgstr "" + +msgid "ForkProject|Visibility level" +msgstr "" + +msgid "ForkProject|Want to house several dependent projects under the same namespace?" +msgstr "" + msgid "ForkedFromProjectPath|Forked from" msgstr "" @@ -24967,9 +25000,6 @@ msgstr "" msgid "Remove due date" msgstr "" -msgid "Remove file" -msgstr "" - msgid "Remove fork relationship" msgstr "" @@ -28333,9 +28363,6 @@ msgstr "" msgid "Start a new merge request" msgstr "" -msgid "Start a new merge request with these changes" -msgstr "" - msgid "Start a review" msgstr "" diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index 8705c22c41a..d7330b5267b 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -198,14 +198,6 @@ RSpec.describe 'Dashboard Projects' do it_behaves_like 'hidden pipeline status' end - context 'when dashboard_pipeline_status is disabled' do - before do - stub_feature_flags(dashboard_pipeline_status: false) - end - - it_behaves_like 'hidden pipeline status' - end - context "when last_pipeline is missing" do before do project.last_pipeline.delete diff --git a/spec/features/projects/files/user_uploads_files_spec.rb b/spec/features/projects/files/user_uploads_files_spec.rb index e8f51837ec9..944d08df3f3 100644 --- a/spec/features/projects/files/user_uploads_files_spec.rb +++ b/spec/features/projects/files/user_uploads_files_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe 'Projects > Files > User uploads files' do + include DropzoneHelper + let(:user) { create(:user) } let(:project) { create(:project, :repository, name: 'Shop', creator: user) } let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') } @@ -15,15 +17,36 @@ RSpec.describe 'Projects > Files > User uploads files' do context 'when a user has write access' do before do visit(project_tree_path(project)) - - wait_for_requests end include_examples 'it uploads and commit a new text file' include_examples 'it uploads and commit a new image file' - include_examples 'it uploads a file to a sub-directory' + it 'uploads a file to a sub-directory', :js do + click_link 'files' + + page.within('.repo-breadcrumb') do + expect(page).to have_content('files') + end + + find('.add-to-tree').click + click_link('Upload file') + drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) + + page.within('#modal-upload-blob') do + fill_in(:commit_message, with: 'New commit message') + end + + click_button('Upload file') + + expect(page).to have_content('New commit message') + + page.within('.repo-breadcrumb') do + expect(page).to have_content('files') + expect(page).to have_content('doc_sample.txt') + end + end end context 'when a user does not have write access' do diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index 8d0500f5e13..59bed5501f2 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -9,6 +9,7 @@ RSpec.describe 'Project fork' do let(:project) { create(:project, :public, :repository) } before do + stub_feature_flags(fork_project_form: false) sign_in(user) end diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb index f1fc3927b03..3cc3c763e29 100644 --- a/spec/features/projects/members/list_spec.rb +++ b/spec/features/projects/members/list_spec.rb @@ -127,7 +127,7 @@ RSpec.describe 'Project members list' do it 'does not show form used to change roles and "Expiration date" or the remove user button' do visit_members_page - page.within find_member_row(project_bot) do + page.within find_username_row(project_bot) do expect(page).not_to have_button('Maintainer') expect(page).to have_field('Expiration date', disabled: true) expect(page).not_to have_button('Remove member') diff --git a/spec/features/projects/show/user_uploads_files_spec.rb b/spec/features/projects/show/user_uploads_files_spec.rb index 5749ffd0650..b7c5d324d93 100644 --- a/spec/features/projects/show/user_uploads_files_spec.rb +++ b/spec/features/projects/show/user_uploads_files_spec.rb @@ -17,15 +17,11 @@ RSpec.describe 'Projects > Show > User uploads files' do context 'when a user has write access' do before do visit(project_path(project)) - - wait_for_requests end include_examples 'it uploads and commit a new text file' include_examples 'it uploads and commit a new image file' - - include_examples 'it uploads a file to a sub-directory' end context 'when a user does not have write access' do diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js new file mode 100644 index 00000000000..5aafb1e8d2e --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -0,0 +1,273 @@ +import { GlForm, GlFormInputGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; +import httpStatus from '~/lib/utils/http_status'; +import * as urlUtility from '~/lib/utils/url_utility'; +import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('ForkForm component', () => { + let wrapper; + let axiosMock; + + const GON_GITLAB_URL = 'https://gitlab.com'; + const GON_API_VERSION = 'v7'; + + const MOCK_NAMESPACES_RESPONSE = [ + { + name: 'one', + id: 1, + }, + { + name: 'two', + id: 2, + }, + ]; + + const DEFAULT_PROPS = { + endpoint: '/some/project-full-path/-/forks/new.json', + newGroupPath: 'some/groups/path', + projectFullPath: '/some/project-full-path', + visibilityHelpPath: 'some/visibility/help/path', + projectId: '10', + projectName: 'Project Name', + projectPath: 'project-name', + projectDescription: 'some project description', + projectVisibility: 'private', + }; + + const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => { + axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data); + }; + + const createComponent = (props = {}, data = {}) => { + wrapper = shallowMount(ForkForm, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + data() { + return { + ...data, + }; + }, + stubs: { + GlFormInputGroup, + }, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + window.gon = { + gitlab_url: GON_GITLAB_URL, + api_version: GON_API_VERSION, + }; + }); + + afterEach(() => { + wrapper.destroy(); + axiosMock.restore(); + }); + + const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]'); + const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]'); + const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]'); + const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]'); + const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]'); + const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]'); + const findForkDescriptionTextarea = () => + wrapper.find('[data-testid="fork-description-textarea"]'); + const findVisibilityRadioGroup = () => + wrapper.find('[data-testid="fork-visibility-radio-group"]'); + + it('will go to projectFullPath when click cancel button', () => { + mockGetRequest(); + createComponent(); + + const { projectFullPath } = DEFAULT_PROPS; + const cancelButton = wrapper.find('[data-testid="cancel-button"]'); + + expect(cancelButton.attributes('href')).toBe(projectFullPath); + }); + + it('make POST request with project param', async () => { + jest.spyOn(axios, 'post'); + + const namespaceId = 20; + + mockGetRequest(); + createComponent( + {}, + { + selectedNamespace: { + id: namespaceId, + }, + }, + ); + + wrapper.find(GlForm).vm.$emit('submit', { preventDefault: () => {} }); + + const { + projectId, + projectDescription, + projectName, + projectPath, + projectVisibility, + } = DEFAULT_PROPS; + + const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`; + const project = { + description: projectDescription, + id: projectId, + name: projectName, + namespace_id: namespaceId, + path: projectPath, + visibility: projectVisibility, + }; + + expect(axios.post).toHaveBeenCalledWith(url, project); + }); + + it('has input with csrf token', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('pre-populate form from project props', () => { + mockGetRequest(); + createComponent(); + + expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROPS.projectName); + expect(findForkSlugInput().attributes('value')).toBe(DEFAULT_PROPS.projectPath); + expect(findForkDescriptionTextarea().attributes('value')).toBe( + DEFAULT_PROPS.projectDescription, + ); + }); + + it('sets project URL prepend text with gon.gitlab_url', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`); + }); + + it('will have required attribute for required fields', () => { + mockGetRequest(); + createComponent(); + + expect(findForkNameInput().attributes('required')).not.toBeUndefined(); + expect(findForkUrlInput().attributes('required')).not.toBeUndefined(); + expect(findForkSlugInput().attributes('required')).not.toBeUndefined(); + expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined(); + expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined(); + }); + + describe('forks namespaces', () => { + beforeEach(() => { + mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE }); + createComponent(); + }); + + it('make GET request from endpoint', async () => { + await axios.waitForAll(); + + expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint); + }); + + it('generate default option', async () => { + await axios.waitForAll(); + + const optionsArray = findForkUrlInput().findAll('option'); + + expect(optionsArray.at(0).text()).toBe('Select a namespace'); + }); + + it('populate project url namespace options', async () => { + await axios.waitForAll(); + + const optionsArray = findForkUrlInput().findAll('option'); + + expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1); + expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].name); + expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].name); + }); + }); + + describe('visibility level', () => { + it.each` + project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'private'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} + ${'private'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} + ${'internal'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} + ${'internal'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} + ${'public'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'public'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} + ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${undefined} + `( + 'sets appropriate radio button disabled state', + async ({ project, namespace, privateIsDisabled, internalIsDisabled, publicIsDisabled }) => { + mockGetRequest(); + createComponent( + { + projectVisibility: project, + }, + { + selectedNamespace: { + visibility: namespace, + }, + }, + ); + + expect(findPrivateRadio().attributes('disabled')).toBe(privateIsDisabled); + expect(findInternalRadio().attributes('disabled')).toBe(internalIsDisabled); + expect(findPublicRadio().attributes('disabled')).toBe(publicIsDisabled); + }, + ); + }); + + describe('onSubmit', () => { + beforeEach(() => { + jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); + }); + + it('redirect to POST web_url response', async () => { + const webUrl = `new/fork-project`; + + jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); + + mockGetRequest(); + createComponent(); + + await wrapper.vm.onSubmit(); + + expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); + }); + + it('display flash when POST is unsuccessful', async () => { + const dummyError = 'Fork project failed'; + + jest.spyOn(axios, 'post').mockRejectedValue(dummyError); + + mockGetRequest(); + createComponent(); + + await wrapper.vm.onSubmit(); + + expect(urlUtility.redirectTo).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ + message: dummyError, + }); + }); + }); +}); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 93bfd3d9d32..2ac2069a177 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -1,36 +1,24 @@ import { GlDropdown } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; -import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; -describe('Repository breadcrumbs component', () => { - let wrapper; - - const factory = (currentPath, extraProps = {}) => { - const $apollo = { - queries: { - userPermissions: { - loading: true, - }, - }, - }; - - wrapper = shallowMount(Breadcrumbs, { - propsData: { - currentPath, - ...extraProps, - }, - stubs: { - RouterLink: RouterLinkStub, - }, - mocks: { $apollo }, - }); - }; - - const findUploadBlobModal = () => wrapper.find(UploadBlobModal); +let vm; + +function factory(currentPath, extraProps = {}) { + vm = shallowMount(Breadcrumbs, { + propsData: { + currentPath, + ...extraProps, + }, + stubs: { + RouterLink: RouterLinkStub, + }, + }); +} +describe('Repository breadcrumbs component', () => { afterEach(() => { - wrapper.destroy(); + vm.destroy(); }); it.each` @@ -42,13 +30,13 @@ describe('Repository breadcrumbs component', () => { `('renders $linkCount links for path $path', ({ path, linkCount }) => { factory(path); - expect(wrapper.findAll(RouterLinkStub).length).toEqual(linkCount); + expect(vm.findAll(RouterLinkStub).length).toEqual(linkCount); }); it('escapes hash in directory path', () => { factory('app/assets/javascripts#'); - expect(wrapper.findAll(RouterLinkStub).at(3).props('to')).toEqual( + expect(vm.findAll(RouterLinkStub).at(3).props('to')).toEqual( '/-/tree/app/assets/javascripts%23', ); }); @@ -56,44 +44,26 @@ describe('Repository breadcrumbs component', () => { it('renders last link as active', () => { factory('app/assets'); - expect(wrapper.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page'); + expect(vm.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page'); }); - it('does not render add to tree dropdown when permissions are false', async () => { + it('does not render add to tree dropdown when permissions are false', () => { factory('/', { canCollaborate: false }); - wrapper.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); + vm.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); - await wrapper.vm.$nextTick(); - - expect(wrapper.find(GlDropdown).exists()).toBe(false); + return vm.vm.$nextTick(() => { + expect(vm.find(GlDropdown).exists()).toBe(false); + }); }); - it('renders add to tree dropdown when permissions are true', async () => { + it('renders add to tree dropdown when permissions are true', () => { factory('/', { canCollaborate: true }); - wrapper.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); - - await wrapper.vm.$nextTick(); - - expect(wrapper.find(GlDropdown).exists()).toBe(true); - }); - - describe('renders the upload blob modal', () => { - beforeEach(() => { - factory('/', { canEditTree: true }); - }); - - it('does not render the modal while loading', () => { - expect(findUploadBlobModal().exists()).toBe(false); - }); - - it('renders the modal once loaded', async () => { - wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); - - await wrapper.vm.$nextTick(); + vm.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); - expect(findUploadBlobModal().exists()).toBe(true); + return vm.vm.$nextTick(() => { + expect(vm.find(GlDropdown).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js deleted file mode 100644 index 6e3cbad558d..00000000000 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ /dev/null @@ -1,193 +0,0 @@ -import { GlModal, GlFormInput, GlFormTextarea, GlToggle, GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import httpStatusCodes from '~/lib/utils/http_status'; -import { visitUrl } from '~/lib/utils/url_utility'; -import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; -import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; - -jest.mock('~/flash'); -jest.mock('~/lib/utils/url_utility', () => ({ - visitUrl: jest.fn(), - joinPaths: () => '/new_upload', -})); - -const initialProps = { - modalId: 'upload-blob', - commitMessage: 'Upload New File', - targetBranch: 'master', - origionalBranch: 'master', - canPushCode: true, - path: 'new_upload', -}; - -describe('UploadBlobModal', () => { - let wrapper; - let mock; - - const mockEvent = { preventDefault: jest.fn() }; - - const createComponent = (props) => { - wrapper = shallowMount(UploadBlobModal, { - propsData: { - ...initialProps, - ...props, - }, - mocks: { - $route: { - params: { - path: '', - }, - }, - }, - }); - }; - - const findModal = () => wrapper.find(GlModal); - const findAlert = () => wrapper.find(GlAlert); - const findCommitMessage = () => wrapper.find(GlFormTextarea); - const findBranchName = () => wrapper.find(GlFormInput); - const findMrToggle = () => wrapper.find(GlToggle); - const findUploadDropzone = () => wrapper.find(UploadDropzone); - const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes[0].disabled; - const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes[0].disabled; - const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes[0].loading; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe.each` - canPushCode | displayBranchName | displayForkedBranchMessage - ${true} | ${true} | ${false} - ${false} | ${false} | ${true} - `( - 'canPushCode = $canPushCode', - ({ canPushCode, displayBranchName, displayForkedBranchMessage }) => { - beforeEach(() => { - createComponent({ canPushCode }); - }); - - it('displays the modal', () => { - expect(findModal().exists()).toBe(true); - }); - - it('includes the upload dropzone', () => { - expect(findUploadDropzone().exists()).toBe(true); - }); - - it('includes the commit message', () => { - expect(findCommitMessage().exists()).toBe(true); - }); - - it('displays the disabled upload button', () => { - expect(actionButtonDisabledState()).toBe(true); - }); - - it('displays the enabled cancel button', () => { - expect(cancelButtonDisabledState()).toBe(false); - }); - - it('does not display the MR toggle', () => { - expect(findMrToggle().exists()).toBe(false); - }); - - it(`${ - displayForkedBranchMessage ? 'displays' : 'does not display' - } the forked branch message`, () => { - expect(findAlert().exists()).toBe(displayForkedBranchMessage); - }); - - it(`${displayBranchName ? 'displays' : 'does not display'} the branch name`, () => { - expect(findBranchName().exists()).toBe(displayBranchName); - }); - - if (canPushCode) { - describe('when changing the branch name', () => { - it('displays the MR toggle', async () => { - wrapper.setData({ target: 'Not master' }); - - await wrapper.vm.$nextTick(); - - expect(findMrToggle().exists()).toBe(true); - }); - }); - } - - describe('completed form', () => { - beforeEach(() => { - wrapper.setData({ - file: { type: 'jpg' }, - filePreviewURL: 'http://file.com?format=jpg', - }); - }); - - it('enables the upload button when the form is completed', () => { - expect(actionButtonDisabledState()).toBe(false); - }); - - describe('form submission', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - - findModal().vm.$emit('primary', mockEvent); - }); - - afterEach(() => { - mock.restore(); - }); - - it('disables the upload button', () => { - expect(actionButtonDisabledState()).toBe(true); - }); - - it('sets the upload button to loading', () => { - expect(actionButtonLoadingState()).toBe(true); - }); - }); - - describe('successful response', () => { - beforeEach(async () => { - mock = new MockAdapter(axios); - mock.onPost(initialProps.path).reply(httpStatusCodes.OK, { filePath: 'blah' }); - - findModal().vm.$emit('primary', mockEvent); - - await waitForPromises(); - }); - - it('redirects to the uploaded file', () => { - expect(visitUrl).toHaveBeenCalled(); - }); - - afterEach(() => { - mock.restore(); - }); - }); - - describe('error response', () => { - beforeEach(async () => { - mock = new MockAdapter(axios); - mock.onPost(initialProps.path).timeout(); - - findModal().vm.$emit('primary', mockEvent); - - await waitForPromises(); - }); - - it('creates a flash error', () => { - expect(createFlash).toHaveBeenCalledWith('Error uploading file. Please try again.'); - }); - - afterEach(() => { - mock.restore(); - }); - }); - }); - }, - ); -}); diff --git a/spec/graphql/features/authorization_spec.rb b/spec/graphql/features/authorization_spec.rb index ec67ed16fe9..33b11e1ca09 100644 --- a/spec/graphql/features/authorization_spec.rb +++ b/spec/graphql/features/authorization_spec.rb @@ -2,17 +2,22 @@ require 'spec_helper' -RSpec.describe 'Gitlab::Graphql::Authorization' do +RSpec.describe 'Gitlab::Graphql::Authorize' do include GraphqlHelpers + include Graphql::ResolverFactories let_it_be(:user) { create(:user) } let(:permission_single) { :foo } let(:permission_collection) { [:foo, :bar] } let(:test_object) { double(name: 'My name') } let(:query_string) { '{ item { name } }' } - let(:result) { execute_query(query_type)['data'] } + let(:result) do + schema = empty_schema + schema.use(Gitlab::Graphql::Authorize) + execute_query(query_type, schema: schema) + end - subject { result['item'] } + subject { result.dig('data', 'item') } shared_examples 'authorization with a single permission' do it 'returns the protected field when user has permission' do @@ -55,7 +60,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'with a single permission' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_single + query.field :item, type, null: true, resolver: new_resolver(test_object), authorize: permission_single end end @@ -66,7 +71,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do permissions = permission_collection query_factory do |qt| - qt.field :item, type, null: true, resolver: simple_resolver(test_object) do + qt.field :item, type, null: true, resolver: new_resolver(test_object) do authorize permissions end end @@ -79,7 +84,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'Field authorizations when field is a built in type' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolver: simple_resolver(test_object) + query.field :item, type, null: true, resolver: new_resolver(test_object) end end @@ -132,7 +137,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'Type authorizations' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolver: simple_resolver(test_object) + query.field :item, type, null: true, resolver: new_resolver(test_object) end end @@ -169,7 +174,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_2 + query.field :item, type, null: true, resolver: new_resolver(test_object), authorize: permission_2 end end @@ -188,11 +193,11 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do query_factory do |query| - query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object]) + query.field :item, type.connection_type, null: true, resolver: new_resolver([test_object, second_test_object]) end end - subject { result.dig('item', 'edges') } + subject { result.dig('data', 'item', 'edges') } it 'returns only the elements visible to the user' do permit(permission_single) @@ -208,7 +213,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'limiting connections with multiple objects' do let(:query_type) do query_factory do |query| - query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object]) + query.field :item, type.connection_type, null: true, resolver: new_resolver([test_object, second_test_object]) end end @@ -232,11 +237,11 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do query_factory do |query| - query.field :item, [type], null: true, resolver: simple_resolver([test_object]) + query.field :item, [type], null: true, resolver: new_resolver([test_object]) end end - subject { result['item'].first } + subject { result.dig('data', 'item', 0) } include_examples 'authorization with a single permission' end @@ -260,13 +265,13 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do type_factory do |type| type.graphql_name 'FakeProjectType' type.field :test_issues, issue_type.connection_type, null: false, - resolver: simple_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc)) + resolver: new_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc)) end end let(:query_type) do query_factory do |query| - query.field :test_project, project_type, null: false, resolver: simple_resolver(visible_project) + query.field :test_project, project_type, null: false, resolver: new_resolver(visible_project) end end @@ -281,7 +286,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do end it 'renders the issues the user has access to' do - issue_edges = result['testProject']['testIssues']['edges'] + issue_edges = result.dig('data', 'testProject', 'testIssues', 'edges') issue_ids = issue_edges.map { |issue_edge| issue_edge['node']&.fetch('id') } expect(issue_edges.size).to eq(visible_issues.size) diff --git a/spec/graphql/features/feature_flag_spec.rb b/spec/graphql/features/feature_flag_spec.rb index 77810f78257..30238cf9cb3 100644 --- a/spec/graphql/features/feature_flag_spec.rb +++ b/spec/graphql/features/feature_flag_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe 'Graphql Field feature flags' do include GraphqlHelpers + include Graphql::ResolverFactories let_it_be(:user) { create(:user) } @@ -23,7 +24,7 @@ RSpec.describe 'Graphql Field feature flags' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, feature_flag: feature_flag, resolver: simple_resolver(test_object) + query.field :item, type, null: true, feature_flag: feature_flag, resolver: new_resolver(test_object) end end diff --git a/spec/graphql/mutations/release_asset_links/create_spec.rb b/spec/graphql/mutations/release_asset_links/create_spec.rb index e7bac6b3287..089bc3d3276 100644 --- a/spec/graphql/mutations/release_asset_links/create_spec.rb +++ b/spec/graphql/mutations/release_asset_links/create_spec.rb @@ -21,9 +21,9 @@ RSpec.describe Mutations::ReleaseAssetLinks::Create do let(:args) do { project_path: project_path, - tag: tag, + tag_name: tag, name: name, - filepath: filepath, + direct_asset_path: filepath, url: url } end @@ -44,9 +44,9 @@ RSpec.describe Mutations::ReleaseAssetLinks::Create do expect(release.links.length).to be(1) - expect(last_release_link.name).to eq(args[:name]) - expect(last_release_link.url).to eq(args[:url]) - expect(last_release_link.filepath).to eq(args[:filepath]) + expect(last_release_link.name).to eq(name) + expect(last_release_link.url).to eq(url) + expect(last_release_link.filepath).to eq(filepath) end end diff --git a/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb b/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb index 5370f7a7433..e9e7fff6e6e 100644 --- a/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb +++ b/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb @@ -9,7 +9,6 @@ RSpec.describe ::CachingArrayResolver do let_it_be(:admins) { create_list(:user, 4, admin: true) } let(:query_context) { { current_user: admins.first } } let(:max_page_size) { 10 } - let(:field) { double('Field', max_page_size: max_page_size) } let(:schema) do Class.new(GitlabSchema) do default_max_page_size 3 @@ -210,6 +209,6 @@ RSpec.describe ::CachingArrayResolver do args = { is_admin: admin } opts = resolver.field_options allow(resolver).to receive(:field_options).and_return(opts.merge(max_page_size: max_page_size)) - resolve(resolver, args: args, ctx: query_context, schema: schema, field: field) + resolve(resolver, args: args, ctx: query_context, schema: schema) end end diff --git a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb index 170a602fb0d..68badb8e333 100644 --- a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb +++ b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do end describe '#resolve' do - context 'insufficient user permission' do + context 'with insufficient user permission' do let(:user) { create(:user) } it 'returns nil' do @@ -29,7 +29,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do end end - context 'user with permission' do + context 'with sufficient permission' do before do project.add_developer(current_user) @@ -93,7 +93,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do end it 'returns an externally paginated array' do - expect(resolve_errors).to be_a Gitlab::Graphql::ExternallyPaginatedArray + expect(resolve_errors).to be_a Gitlab::Graphql::Pagination::ExternallyPaginatedArrayConnection end end end diff --git a/spec/graphql/resolvers/group_labels_resolver_spec.rb b/spec/graphql/resolvers/group_labels_resolver_spec.rb index ed94f12502a..3f4ad8760c0 100644 --- a/spec/graphql/resolvers/group_labels_resolver_spec.rb +++ b/spec/graphql/resolvers/group_labels_resolver_spec.rb @@ -42,7 +42,7 @@ RSpec.describe Resolvers::GroupLabelsResolver do context 'without parent' do it 'returns no labels' do - expect(resolve_labels(nil)).to eq(Label.none) + expect(resolve_labels(nil)).to be_empty end end diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 8980f4aa19d..6e802bf7d25 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -264,7 +264,7 @@ RSpec.describe Resolvers::IssuesResolver do end it 'finds a specific issue with iid', :request_store do - result = batch_sync(max_queries: 4) { resolve_issues(iid: issue1.iid) } + result = batch_sync(max_queries: 4) { resolve_issues(iid: issue1.iid).to_a } expect(result).to contain_exactly(issue1) end @@ -281,7 +281,7 @@ RSpec.describe Resolvers::IssuesResolver do it 'finds a specific issue with iids', :request_store do result = batch_sync(max_queries: 4) do - resolve_issues(iids: [issue1.iid]) + resolve_issues(iids: [issue1.iid]).to_a end expect(result).to contain_exactly(issue1) @@ -290,7 +290,7 @@ RSpec.describe Resolvers::IssuesResolver do it 'finds multiple issues with iids' do create(:issue, project: project, author: current_user) - expect(batch_sync { resolve_issues(iids: [issue1.iid, issue2.iid]) }) + expect(batch_sync { resolve_issues(iids: [issue1.iid, issue2.iid]).to_a }) .to contain_exactly(issue1, issue2) end @@ -302,7 +302,7 @@ RSpec.describe Resolvers::IssuesResolver do create(:issue, project: another_project, iid: iid) end - expect(batch_sync { resolve_issues(iids: iids) }).to contain_exactly(issue1, issue2) + expect(batch_sync { resolve_issues(iids: iids).to_a }).to contain_exactly(issue1, issue2) end end end diff --git a/spec/graphql/resolvers/labels_resolver_spec.rb b/spec/graphql/resolvers/labels_resolver_spec.rb index 3d027a6c8d5..be6229553d7 100644 --- a/spec/graphql/resolvers/labels_resolver_spec.rb +++ b/spec/graphql/resolvers/labels_resolver_spec.rb @@ -42,50 +42,36 @@ RSpec.describe Resolvers::LabelsResolver do context 'without parent' do it 'returns no labels' do - expect(resolve_labels(nil)).to eq(Label.none) + expect(resolve_labels(nil)).to be_empty end end - context 'at project level' do + context 'with a parent project' do before_all do group.add_developer(current_user) end - # because :include_ancestor_groups, :include_descendant_groups, :only_group_labels default to false - # the `nil` value would be equivalent to passing in `false` so just check for `nil` option - where(:include_ancestor_groups, :include_descendant_groups, :only_group_labels, :search_term, :test) do - nil | nil | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2) } - nil | nil | true | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2) } - nil | true | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) } - nil | true | true | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) } - true | nil | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2) } - true | nil | true | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2) } - true | true | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) } - true | true | true | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) } - - nil | nil | nil | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2) } - nil | nil | true | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2) } - nil | true | nil | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2, sub_subgroup_label2) } - nil | true | true | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2, sub_subgroup_label2) } - true | nil | nil | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2) } - true | nil | true | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2) } - true | true | nil | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2, sub_subgroup_label2) } - true | true | true | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2, sub_subgroup_label2) } + # the expected result is wrapped in a lambda to get around the phase restrictions of RSpec::Parameterized + where(:include_ancestor_groups, :search_term, :expected_labels) do + nil | nil | -> { [label1, label2, subgroup_label1, subgroup_label2] } + false | nil | -> { [label1, label2, subgroup_label1, subgroup_label2] } + true | nil | -> { [label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2] } + nil | 'new' | -> { [label2, subgroup_label2] } + false | 'new' | -> { [label2, subgroup_label2] } + true | 'new' | -> { [label2, group_label2, subgroup_label2] } end with_them do let(:params) do { include_ancestor_groups: include_ancestor_groups, - include_descendant_groups: include_descendant_groups, - only_group_labels: only_group_labels, search_term: search_term } end subject { resolve_labels(project, params) } - it { self.instance_exec(&test) } + specify { expect(subject).to match_array(instance_exec(&expected_labels)) } end end end diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb index c5c368fc88f..7dd968d90a8 100644 --- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb +++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb @@ -69,7 +69,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do it 'batch-resolves by target project full path and IIDS', :request_store do result = batch_sync(max_queries: queries_per_project) do - resolve_mr(project, iids: [iid_1, iid_2]) + resolve_mr(project, iids: [iid_1, iid_2]).to_a end expect(result).to contain_exactly(merge_request_1, merge_request_2) diff --git a/spec/graphql/resolvers/release_milestones_resolver_spec.rb b/spec/graphql/resolvers/release_milestones_resolver_spec.rb index f05069998d0..a5a523859f9 100644 --- a/spec/graphql/resolvers/release_milestones_resolver_spec.rb +++ b/spec/graphql/resolvers/release_milestones_resolver_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Resolvers::ReleaseMilestonesResolver do describe '#resolve' do it "uses offset-pagination" do - expect(resolved).to be_a(::Gitlab::Graphql::Pagination::OffsetPaginatedRelation) + expect(resolved).to be_a(::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection) end it "includes the release's milestones in the returned OffsetActiveRecordRelationConnection" do diff --git a/spec/lib/api/entities/user_spec.rb b/spec/lib/api/entities/user_spec.rb index e35deeb6263..0deaf476977 100644 --- a/spec/lib/api/entities/user_spec.rb +++ b/spec/lib/api/entities/user_spec.rb @@ -35,4 +35,22 @@ RSpec.describe API::Entities::User do expect(subject[:bot]).to eq(true) end end + + context 'with project bot user' do + let(:user) { create(:user, :project_bot) } + + context 'when the requester is not an admin' do + it 'does not expose project bot user name' do + expect(subject).not_to include(:name) + end + end + + context 'when the requester is an admin' do + let(:current_user) { create(:user, :admin) } + + it 'exposes project bot user name' do + expect(subject).to include(:name) + end + end + end end diff --git a/spec/support/graphql/resolver_factories.rb b/spec/support/graphql/resolver_factories.rb new file mode 100644 index 00000000000..8188f17cc43 --- /dev/null +++ b/spec/support/graphql/resolver_factories.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Graphql + module ResolverFactories + def new_resolver(resolved_value = 'Resolved value', method: :resolve) + case method + when :resolve + simple_resolver(resolved_value) + when :find_object + find_object_resolver(resolved_value) + else + raise "Cannot build a resolver for #{method}" + end + end + + private + + def simple_resolver(resolved_value = 'Resolved value') + Class.new(Resolvers::BaseResolver) do + define_method :resolve do |**_args| + resolved_value + end + end + end + + def find_object_resolver(resolved_value = 'Found object') + Class.new(Resolvers::BaseResolver) do + include ::Gitlab::Graphql::Authorize::AuthorizeResource + + def resolve(**args) + authorized_find!(**args) + end + + define_method :find_object do |**_args| + resolved_value + end + end + end + end +end diff --git a/spec/support/helpers/features/members_table_helpers.rb b/spec/support/helpers/features/members_table_helpers.rb index 4a0e218ed3e..80fd4bcf07a 100644 --- a/spec/support/helpers/features/members_table_helpers.rb +++ b/spec/support/helpers/features/members_table_helpers.rb @@ -41,6 +41,10 @@ module Spec find_row(user.name) end + def find_username_row(user) + find_row(user.username) + end + def find_invited_member_row(email) find_row(email) end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 46d0c13dc18..0e05ca320ed 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -16,32 +16,127 @@ module GraphqlHelpers underscored_field_name.to_s.camelize(:lower) end - # Run a loader's named resolver in a way that closely mimics the framework. + def self.deep_fieldnamerize(map) + map.to_h do |k, v| + [fieldnamerize(k), v.is_a?(Hash) ? deep_fieldnamerize(v) : v] + end + end + + # Run this resolver exactly as it would be called in the framework. This + # includes all authorization hooks, all argument processing and all result + # wrapping. + # see: GraphqlHelpers#resolve_field + def resolve( + resolver_class, # [Class[<= BaseResolver]] The resolver at test. + obj: nil, # [Any] The BaseObject#object for the resolver (available as `#object` in the resolver). + args: {}, # [Hash] The arguments to the resolver (using client names). + ctx: {}, # [#to_h] The current context values. + schema: GitlabSchema, # [GraphQL::Schema] Schema to use during execution. + parent: :not_given, # A GraphQL query node to be passed as the `:parent` extra. + lookahead: :not_given # A GraphQL lookahead object to be passed as the `:lookahead` extra. + ) + # All resolution goes through fields, so we need to create one here that + # uses our resolver. Thankfully, apart from the field name, resolvers + # contain all the configuration needed to define one. + field_options = resolver_class.field_options.merge(name: 'field_value') + field = ::Types::BaseField.new(**field_options) + + # All mutations accept a single `:input` argument. Wrap arguments here. + # See the unwrapping below in GraphqlHelpers#resolve_field + args = { input: args } if resolver_class <= ::Mutations::BaseMutation && !args.key?(:input) + + resolve_field(field, obj, + args: args, + ctx: ctx, + schema: schema, + object_type: resolver_parent, + extras: { parent: parent, lookahead: lookahead }) + end + + # Resolve the value of a field on an object. + # + # Use this method to test individual fields within type specs. + # + # e.g. + # + # issue = create(:issue) + # user = issue.author + # project = issue.project # - # First the `ready?` method is called. If it turns out that the resolver is not - # ready, then the early return is returned instead. + # resolve_field(:author, issue, current_user: user, object_type: ::Types::IssueType) + # resolve_field(:issue, project, args: { iid: issue.iid }, current_user: user, object_type: ::Types::ProjectType) # - # Then the resolve method is called. - def resolve(resolver_class, args: {}, lookahead: :not_given, parent: :not_given, **resolver_args) - args = aliased_args(resolver_class, args) - args[:parent] = parent unless parent == :not_given - args[:lookahead] = lookahead unless lookahead == :not_given - resolver = resolver_instance(resolver_class, **resolver_args) - ready, early_return = sync_all { resolver.ready?(**args) } + # The `object_type` defaults to the `described_class`, so when called from type specs, + # the above can be written as: + # + # # In project_type_spec.rb + # resolve_field(:author, issue, current_user: user) + # + # # In issue_type_spec.rb + # resolve_field(:issue, project, args: { iid: issue.iid }, current_user: user) + # + # NB: Arguments are passed from the client's perspective. If there is an argument + # `foo` aliased as `bar`, then we would pass `args: { bar: the_value }`, and + # types are checked before resolution. + def resolve_field( + field, # An instance of `BaseField`, or the name of a field on the current described_class + object, # The current object of the `BaseObject` this field 'belongs' to + args: {}, # Field arguments (keys will be fieldnamerized) + ctx: {}, # Context values (important ones are :current_user) + extras: {}, # Stub values for field extras (parent and lookahead) + current_user: :not_given, # The current user (specified explicitly, overrides ctx[:current_user]) + schema: GitlabSchema, # A specific schema instance + object_type: described_class # The `BaseObject` type this field belongs to + ) + field = to_base_field(field, object_type) + ctx[:current_user] = current_user unless current_user == :not_given + query = GraphQL::Query.new(schema, context: ctx.to_h) + extras[:lookahead] = negative_lookahead if extras[:lookahead] == :not_given && field.extras.include?(:lookahead) + + query_ctx = query.context + + mock_extras(query_ctx, **extras) + + parent = object_type.authorized_new(object, query_ctx) + raise UnauthorizedObject unless parent + + # TODO: This will need to change when we move to the interpreter: + # At that point, arguments will be a plain ruby hash rather than + # an Arguments object + # see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536 + # https://gitlab.com/gitlab-org/gitlab/-/issues/210556 + arguments = field.to_graphql.arguments_class.new( + GraphqlHelpers.deep_fieldnamerize(args), + context: query_ctx, + defaults_used: [] + ) + + # we enable the request store so we can track gitaly calls. + ::Gitlab::WithRequestStore.with_request_store do + # TODO: This will need to change when we move to the interpreter - at that + # point we will call `field#resolve` - return early_return unless ready + # Unwrap the arguments to mutations. This pairs with the wrapping in GraphqlHelpers#resolve + # If arguments are not wrapped first, then arguments processing will raise. + # If arguments are not unwrapped here, then the resolve method of the mutation will raise argument errors. + arguments = arguments.to_kwargs[:input] if field.resolver && field.resolver <= ::Mutations::BaseMutation - resolver.resolve(**args) + field.resolve_field(parent, arguments, query_ctx) + end end - # TODO: Remove this method entirely when GraphqlHelpers uses real resolve_field - # see: https://gitlab.com/gitlab-org/gitlab/-/issues/287791 - def aliased_args(resolver, args) - definitions = resolver.arguments + def mock_extras(context, parent: :not_given, lookahead: :not_given) + allow(context).to receive(:parent).and_return(parent) unless parent == :not_given + allow(context).to receive(:lookahead).and_return(lookahead) unless lookahead == :not_given + end - args.transform_keys do |k| - definitions[GraphqlHelpers.fieldnamerize(k)]&.keyword || k - end + # a synthetic BaseObject type to be used in resolver specs. See `GraphqlHelpers#resolve` + def resolver_parent + @resolver_parent ||= fresh_object_type('ResolverParent') + end + + def fresh_object_type(name = 'Object') + Class.new(::Types::BaseObject) { graphql_name name } end def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema) @@ -124,9 +219,9 @@ module GraphqlHelpers lazy_vals.is_a?(Array) ? lazy_vals.map { |val| sync(val) } : sync(lazy_vals) end - def graphql_query_for(name, attributes = {}, fields = nil) + def graphql_query_for(name, args = {}, selection = nil) type = GitlabSchema.types['Query'].fields[GraphqlHelpers.fieldnamerize(name)]&.type - wrap_query(query_graphql_field(name, attributes, fields, type)) + wrap_query(query_graphql_field(name, args, selection, type)) end def wrap_query(query) @@ -171,25 +266,6 @@ module GraphqlHelpers ::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h)).to_json end - def resolve_field(name, object, args = {}, current_user: nil) - q = GraphQL::Query.new(GitlabSchema) - context = GraphQL::Query::Context.new(query: q, object: object, values: { current_user: current_user }) - allow(context).to receive(:parent).and_return(nil) - field = described_class.fields.fetch(GraphqlHelpers.fieldnamerize(name)) - instance = described_class.authorized_new(object, context) - raise UnauthorizedObject unless instance - - field.resolve_field(instance, args, context) - end - - def simple_resolver(resolved_value = 'Resolved value') - Class.new(Resolvers::BaseResolver) do - define_method :resolve do |**_args| - resolved_value - end - end - end - # Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys # # prepare_input_for_mutation({ 'my_key' => 1 }) @@ -558,24 +634,26 @@ module GraphqlHelpers end end - def execute_query(query_type) - schema = Class.new(GraphQL::Schema) do - use GraphQL::Pagination::Connections - use Gitlab::Graphql::Authorize - use Gitlab::Graphql::Pagination::Connections - - lazy_resolve ::Gitlab::Graphql::Lazy, :force - - query(query_type) - end + # assumes query_string to be let-bound in the current context + def execute_query(query_type, schema: empty_schema, graphql: query_string) + schema.query(query_type) schema.execute( - query_string, + graphql, context: { current_user: user }, variables: {} ) end + def empty_schema + Class.new(GraphQL::Schema) do + use GraphQL::Pagination::Connections + use Gitlab::Graphql::Pagination::Connections + + lazy_resolve ::Gitlab::Graphql::Lazy, :force + end + end + # A lookahead that selects everything def positive_lookahead double(selects?: true).tap do |selection| @@ -589,6 +667,23 @@ module GraphqlHelpers allow(selection).to receive(:selection).and_return(selection) end end + + private + + def to_base_field(name_or_field, object_type) + case name_or_field + when ::Types::BaseField + name_or_field + else + field_by_name(name_or_field, object_type) + end + end + + def field_by_name(name, object_type) + name = ::GraphqlHelpers.fieldnamerize(name) + + object_type.fields[name] || (raise ArgumentError, "Unknown field #{name} for #{described_class.graphql_name}") + end end # This warms our schema, doing this as part of loading the helpers to avoid diff --git a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb index 72033ecf187..4411c91d479 100644 --- a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb +++ b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb @@ -10,7 +10,7 @@ RSpec.shared_examples 'it uploads and commit a new text file' do wait_for_requests end - attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') @@ -42,7 +42,7 @@ RSpec.shared_examples 'it uploads and commit a new image file' do wait_for_requests end - attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'), make_visible: true) + drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg')) page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') @@ -70,11 +70,9 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do expect(page).to have_content(fork_message) - wait_for_all_requests - find('.add-to-tree').click click_link('Upload file') - attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') @@ -96,30 +94,3 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do expect(page).to have_content('Sed ut perspiciatis unde omnis') end end - -RSpec.shared_examples 'it uploads a file to a sub-directory' do - it 'uploads a file to a sub-directory', :js do - click_link 'files' - - page.within('.repo-breadcrumb') do - expect(page).to have_content('files') - end - - find('.add-to-tree').click - click_link('Upload file') - attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) - - page.within('#modal-upload-blob') do - fill_in(:commit_message, with: 'New commit message') - end - - click_button('Upload file') - - expect(page).to have_content('New commit message') - - page.within('.repo-breadcrumb') do - expect(page).to have_content('files') - expect(page).to have_content('doc_sample.txt') - end - end -end |