diff options
Diffstat (limited to 'app/assets/javascripts/projects')
10 files changed, 289 insertions, 106 deletions
diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue index eaf93e2da4f..924b6f55db4 100644 --- a/app/assets/javascripts/projects/components/project_delete_button.vue +++ b/app/assets/javascripts/projects/components/project_delete_button.vue @@ -1,12 +1,8 @@ <script> -import { GlAlert, GlSprintf } from '@gitlab/ui'; -import { __ } from '~/locale'; import SharedDeleteButton from './shared/delete_button.vue'; export default { components: { - GlSprintf, - GlAlert, SharedDeleteButton, }, props: { @@ -39,66 +35,17 @@ export default { required: true, }, }, - strings: { - alertTitle: __('You are about to permanently delete this project'), - alertBody: __( - 'After a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.', - ), - isNotForkMessage: __( - 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:', - ), - isForkMessage: __('This forked project has the following:'), - }, }; </script> <template> - <shared-delete-button v-bind="{ confirmPhrase, formPath }"> - <template #modal-body> - <gl-alert - class="gl-mb-5" - variant="danger" - :title="$options.strings.alertTitle" - :dismissible="false" - > - <p> - <gl-sprintf v-if="isFork" :message="$options.strings.isForkMessage" /> - <gl-sprintf v-else :message="$options.strings.isNotForkMessage"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <ul> - <li> - <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)"> - <template #issuesCount>{{ issuesCount }}</template> - </gl-sprintf> - </li> - <li> - <gl-sprintf - :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)" - > - <template #mergeRequestsCount>{{ mergeRequestsCount }}</template> - </gl-sprintf> - </li> - <li> - <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)"> - <template #forksCount>{{ forksCount }}</template> - </gl-sprintf> - </li> - <li> - <gl-sprintf :message="n__('%d star', '%d stars', starsCount)"> - <template #starsCount>{{ starsCount }}</template> - </gl-sprintf> - </li> - </ul> - <gl-sprintf :message="$options.strings.alertBody"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </gl-alert> - </template> - </shared-delete-button> + <shared-delete-button + :confirm-phrase="confirmPhrase" + :form-path="formPath" + :is-fork="isFork" + :issues-count="issuesCount" + :merge-requests-count="mergeRequestsCount" + :forks-count="forksCount" + :stars-count="starsCount" + /> </template> diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue index 2e46f437ace..fd71a246a26 100644 --- a/app/assets/javascripts/projects/components/shared/delete_button.vue +++ b/app/assets/javascripts/projects/components/shared/delete_button.vue @@ -1,14 +1,16 @@ <script> -import { GlModal, GlModalDirective, GlFormInput, GlButton } from '@gitlab/ui'; +import { GlModal, GlModalDirective, GlFormInput, GlButton, GlAlert, GlSprintf } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import csrf from '~/lib/utils/csrf'; import { __ } from '~/locale'; export default { components: { + GlAlert, GlModal, GlFormInput, GlButton, + GlSprintf, }, directives: { GlModal: GlModalDirective, @@ -22,6 +24,26 @@ export default { type: String, required: true, }, + isFork: { + type: Boolean, + required: true, + }, + issuesCount: { + type: Number, + required: true, + }, + mergeRequestsCount: { + type: Number, + required: true, + }, + forksCount: { + type: Number, + required: true, + }, + starsCount: { + type: Number, + required: true, + }, }, data() { return { @@ -55,8 +77,17 @@ export default { }, strings: { deleteProject: __('Delete project'), - title: __('Delete project. Are you ABSOLUTELY SURE?'), - confirmText: __('Please type the following to confirm:'), + title: __('Are you absolutely sure?'), + confirmText: __('Enter the following to confirm:'), + isForkAlertTitle: __('You are about to delete this forked project containing:'), + isNotForkAlertTitle: __('You are about to delete this project containing:'), + isForkAlertBody: __('This process deletes the project repository and all related resources.'), + isNotForkAlertBody: __( + 'This project is %{strongStart}NOT%{strongEnd} a fork. This process deletes the project repository and all related resources.', + ), + isNotForkMessage: __( + 'This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:', + ), }, }; </script> @@ -83,7 +114,52 @@ export default { > <template #modal-title>{{ $options.strings.title }}</template> <div> - <slot name="modal-body"></slot> + <gl-alert class="gl-mb-5" variant="danger" :dismissible="false"> + <h4 v-if="isFork" data-testid="delete-alert-title" class="gl-alert-title"> + {{ $options.strings.isForkAlertTitle }} + </h4> + <h4 v-else data-testid="delete-alert-title" class="gl-alert-title"> + {{ $options.strings.isNotForkAlertTitle }} + </h4> + <ul> + <li> + <gl-sprintf :message="n__('%d issue', '%d issues', issuesCount)"> + <template #issuesCount>{{ issuesCount }}</template> + </gl-sprintf> + </li> + <li> + <gl-sprintf + :message="n__('%d merge requests', '%d merge requests', mergeRequestsCount)" + > + <template #mergeRequestsCount>{{ mergeRequestsCount }}</template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="n__('%d fork', '%d forks', forksCount)"> + <template #forksCount>{{ forksCount }}</template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="n__('%d star', '%d stars', starsCount)"> + <template #starsCount>{{ starsCount }}</template> + </gl-sprintf> + </li> + </ul> + <gl-sprintf + v-if="isFork" + data-testid="delete-alert-body" + :message="$options.strings.isForkAlertBody" + /> + <gl-sprintf + v-else + data-testid="delete-alert-body" + :message="$options.strings.isNotForkAlertBody" + > + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </gl-alert> <p class="gl-mb-1">{{ $options.strings.confirmText }}</p> <p> <code class="gl-white-space-pre-wrap">{{ confirmPhrase }}</code> diff --git a/app/assets/javascripts/projects/new/components/deployment_target_select.vue b/app/assets/javascripts/projects/new/components/deployment_target_select.vue new file mode 100644 index 00000000000..f3b7e39f148 --- /dev/null +++ b/app/assets/javascripts/projects/new/components/deployment_target_select.vue @@ -0,0 +1,61 @@ +<script> +import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { + DEPLOYMENT_TARGET_SELECTIONS, + DEPLOYMENT_TARGET_LABEL, + DEPLOYMENT_TARGET_EVENT, + NEW_PROJECT_FORM, +} from '../constants'; + +const trackingMixin = Tracking.mixin({ label: DEPLOYMENT_TARGET_LABEL }); + +export default { + i18n: { + deploymentTargetLabel: s__('Deployment Target|Project deployment target (optional)'), + defaultOption: s__('Deployment Target|Select the deployment target'), + }, + deploymentTargets: DEPLOYMENT_TARGET_SELECTIONS, + selectId: 'deployment-target-select', + components: { + GlFormGroup, + GlFormSelect, + }, + mixins: [trackingMixin], + data() { + return { + selectedTarget: null, + formSubmitted: false, + }; + }, + mounted() { + const form = document.getElementById(NEW_PROJECT_FORM); + form.addEventListener('submit', () => { + this.formSubmitted = true; + this.trackSelection(); + }); + }, + methods: { + trackSelection() { + if (this.formSubmitted && this.selectedTarget) { + this.track(DEPLOYMENT_TARGET_EVENT, { property: this.selectedTarget }); + } + }, + }, +}; +</script> + +<template> + <gl-form-group :label="$options.i18n.deploymentTargetLabel" :label-for="$options.selectId"> + <gl-form-select + :id="$options.selectId" + v-model="selectedTarget" + :options="$options.deploymentTargets" + > + <template #first> + <option :value="null" disabled>{{ $options.i18n.defaultOption }}</option> + </template> + </gl-form-select> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/projects/new/constants.js b/app/assets/javascripts/projects/new/constants.js new file mode 100644 index 00000000000..c5e6722981b --- /dev/null +++ b/app/assets/javascripts/projects/new/constants.js @@ -0,0 +1,20 @@ +import { s__ } from '~/locale'; + +export const DEPLOYMENT_TARGET_SELECTIONS = [ + s__('DeploymentTarget|Kubernetes (GKE, EKS, OpenShift, and so on)'), + s__('DeploymentTarget|Managed container runtime (Fargate, Cloud Run, DigitalOcean App)'), + s__('DeploymentTarget|Self-managed container runtime (Podman, Docker Swarm, Docker Compose)'), + s__('DeploymentTarget|Heroku'), + s__('DeploymentTarget|Virtual machine (for example, EC2)'), + s__('DeploymentTarget|Mobile app store'), + s__('DeploymentTarget|Registry (package or container)'), + s__('DeploymentTarget|Infrastructure provider (Terraform, Cloudformation, and so on)'), + s__('DeploymentTarget|Serverless backend (Lambda, Cloud functions)'), + s__('DeploymentTarget|GitLab Pages'), + s__('DeploymentTarget|Other hosting service'), + s__('DeploymentTarget|No deployment planned'), +]; + +export const NEW_PROJECT_FORM = 'new_project'; +export const DEPLOYMENT_TARGET_LABEL = 'new_project_deployment_target'; +export const DEPLOYMENT_TARGET_EVENT = 'select_deployment_target'; diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js index 010c6a29ae3..4de9b8a6f47 100644 --- a/app/assets/javascripts/projects/new/index.js +++ b/app/assets/javascripts/projects/new/index.js @@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import NewProjectCreationApp from './components/app.vue'; import NewProjectUrlSelect from './components/new_project_url_select.vue'; +import DeploymentTargetSelect from './components/deployment_target_select.vue'; export function initNewProjectCreation() { const el = document.querySelector('.js-new-project-creation'); @@ -64,3 +65,16 @@ export function initNewProjectUrlSelect() { }), ); } + +export function initDeploymentTargetSelect() { + const el = document.querySelector('.js-deployment-target-select'); + + if (!el) { + return null; + } + + return new Vue({ + el, + render: (createElement) => createElement(DeploymentTargetSelect), + }); +} diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 8d71a3dab68..62e2cec874a 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,6 +1,8 @@ import $ from 'jquery'; import { debounce } from 'lodash'; import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants'; import axios from '../lib/utils/axios_utils'; import { convertToTitleCase, @@ -13,20 +15,26 @@ let hasUserDefinedProjectPath = false; let hasUserDefinedProjectName = false; const invalidInputClass = 'gl-field-error-outline'; +const cancelSource = axios.CancelToken.source(); +const endpoint = `${gon.relative_url_root}/import/url/validate`; +let importCredentialsValidationPromise = null; const validateImportCredentials = (url, user, password) => { - const endpoint = `${gon.relative_url_root}/import/url/validate`; - return axios - .post(endpoint, { - url, - user, - password, - }) + cancelSource.cancel(); + importCredentialsValidationPromise = axios + .post(endpoint, { url, user, password }, { cancelToken: cancelSource.cancel() }) .then(({ data }) => data) - .catch(() => ({ - // intentionally reporting success in case of validation error - // we do not want to block users from trying import in case of validation exception - success: true, - })); + .catch((thrown) => + axios.isCancel(thrown) + ? { + cancelled: true, + } + : { + // intentionally reporting success in case of validation error + // we do not want to block users from trying import in case of validation exception + success: true, + }, + ); + return importCredentialsValidationPromise; }; const onProjectNameChange = ($projectNameInput, $projectPathInput) => { @@ -72,7 +80,7 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => { .parents('.toggle-import-form') .find('#project_path'); - if (hasUserDefinedProjectPath) { + if (hasUserDefinedProjectPath || $currentProjectPath.length === 0) { return; } @@ -98,6 +106,21 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => { }; const bindHowToImport = () => { + const importLinks = document.querySelectorAll('.js-how-to-import-link'); + + importLinks.forEach((link) => { + const { modalTitle: title, modalMessage: modalHtmlMessage } = link.dataset; + + link.addEventListener('click', (e) => { + e.preventDefault(); + confirmAction('', { + modalHtmlMessage, + title, + hideCancel: true, + }); + }); + }); + $('.how_to_import_link').on('click', (e) => { e.preventDefault(); $(e.currentTarget).next('.modal').show(); @@ -114,7 +137,7 @@ const bindEvents = () => { const $projectImportUrlUser = $('#project_import_url_user'); const $projectImportUrlPassword = $('#project_import_url_password'); const $projectImportUrlError = $('.js-import-url-error'); - const $projectImportForm = $('.project-import form'); + const $projectImportForm = $('form.js-project-import'); const $projectPath = $('.tab-pane.active #project_path'); const $useTemplateBtn = $('.template-button > input'); const $projectFieldsForm = $('.project-fields-form'); @@ -124,7 +147,7 @@ const bindEvents = () => { const $projectTemplateButtons = $('.project-templates-buttons'); const $projectName = $('.tab-pane.active #project_name'); - if ($newProjectForm.length !== 1) { + if ($newProjectForm.length !== 1 && $projectImportForm.length !== 1) { return; } @@ -168,20 +191,28 @@ const bindEvents = () => { $projectPath.val($projectPath.val().trim()); }); - const updateUrlPathWarningVisibility = debounce(async () => { - const { success: isUrlValid } = await validateImportCredentials( + const updateUrlPathWarningVisibility = async () => { + const { success: isUrlValid, cancelled } = await validateImportCredentials( $projectImportUrl.val(), $projectImportUrlUser.val(), $projectImportUrlPassword.val(), ); + if (cancelled) { + return; + } + $projectImportUrl.toggleClass(invalidInputClass, !isUrlValid); $projectImportUrlError.toggleClass('hide', isUrlValid); - }, 500); + }; + const debouncedUpdateUrlPathWarningVisibility = debounce( + updateUrlPathWarningVisibility, + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + ); let isProjectImportUrlDirty = false; $projectImportUrl.on('blur', () => { isProjectImportUrlDirty = true; - updateUrlPathWarningVisibility(); + debouncedUpdateUrlPathWarningVisibility(); }); $projectImportUrl.on('keyup', () => { deriveProjectPathFromUrl($projectImportUrl); @@ -190,17 +221,33 @@ const bindEvents = () => { [$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => { $f.on('input', () => { if (isProjectImportUrlDirty) { - updateUrlPathWarningVisibility(); + debouncedUpdateUrlPathWarningVisibility(); } }); }); - $projectImportForm.on('submit', (e) => { + $projectImportForm.on('submit', async (e) => { + e.preventDefault(); + + if (importCredentialsValidationPromise === null) { + // we didn't validate credentials yet + debouncedUpdateUrlPathWarningVisibility.cancel(); + updateUrlPathWarningVisibility(); + } + + const submitBtn = $projectImportForm.find('input[type="submit"]'); + + submitBtn.disable(); + await importCredentialsValidationPromise; + submitBtn.enable(); + const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`); if ($invalidFields.length > 0) { $invalidFields[0].focus(); - e.preventDefault(); - e.stopPropagation(); + } else { + // calling .submit() on HTMLFormElement does not trigger 'submit' event + // We are using this behavior to bypass this handler and avoid infinite loop + $projectImportForm[0].submit(); } }); diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue index 91d8fca0487..aa3235b1515 100644 --- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue +++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue @@ -2,6 +2,7 @@ import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { __, s__ } from '~/locale'; +import { CC_VALIDATION_REQUIRED_ERROR } from '../constants'; const DEFAULT_ERROR_MESSAGE = __('An error occurred while updating the configuration.'); const REQUIRES_VALIDATION_TEXT = s__( @@ -47,11 +48,13 @@ export default { }; }, computed: { - showCreditCardValidation() { + ccRequiredError() { + return this.errorMessage === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed; + }, + genericError() { return ( - this.isCreditCardValidationRequired && - !this.isSharedRunnerEnabled && - !this.successfulValidation && + this.errorMessage && + this.errorMessage !== CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed ); }, @@ -62,6 +65,7 @@ export default { }, toggleSharedRunners() { this.isLoading = true; + this.ccAlertDismissed = false; this.errorMessage = null; axios @@ -82,20 +86,19 @@ export default { <template> <div> <section class="gl-mt-5"> - <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" :dismissible="false"> - {{ errorMessage }} - </gl-alert> - <cc-validation-required-alert - v-if="showCreditCardValidation" + v-if="ccRequiredError" class="gl-pb-5" :custom-message="$options.i18n.REQUIRES_VALIDATION_TEXT" @verifiedCreditCard="creditCardValidated" @dismiss="ccAlertDismissed = true" /> + <gl-alert v-if="genericError" class="gl-mb-3" variant="danger" :dismissible="false"> + {{ errorMessage }} + </gl-alert> + <gl-toggle - v-else ref="sharedRunnersToggle" :disabled="isDisabledAndUnoverridable" :is-loading="isLoading" diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue index b98e1101884..fe968e74c6d 100644 --- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue +++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue @@ -11,8 +11,12 @@ export default { ConfirmDanger, }, props: { - namespaces: { - type: Object, + groupNamespaces: { + type: Array, + required: true, + }, + userNamespaces: { + type: Array, required: true, }, confirmationPhrase: { @@ -44,10 +48,10 @@ export default { <div> <gl-form-group> <namespace-select - class="qa-namespaces-list" data-testid="transfer-project-namespace" :full-width="true" - :data="namespaces" + :group-namespaces="groupNamespaces" + :user-namespaces="userNamespaces" :selected-namespace="selectedNamespace" @select="handleSelect" /> diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js index f5591c43dc4..9cf1afd334f 100644 --- a/app/assets/javascripts/projects/settings/constants.js +++ b/app/assets/javascripts/projects/settings/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const LEVEL_TYPES = { ROLE: 'role', USER: 'user', @@ -18,3 +20,8 @@ export const ACCESS_LEVELS = { }; export const ACCESS_LEVEL_NONE = 0; + +// must match shared_runners_setting in update_service.rb +export const CC_VALIDATION_REQUIRED_ERROR = __( + 'Shared runners enabled cannot be enabled until a valid credit card is on file', +); diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js index 47b49031dc9..a5f720bffaa 100644 --- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js +++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js @@ -3,10 +3,14 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import TransferProjectForm from './components/transfer_project_form.vue'; const prepareNamespaces = (rawNamespaces = '') => { + if (!rawNamespaces) { + return { groupNamespaces: [], userNamespaces: [] }; + } + const data = JSON.parse(rawNamespaces); return { - group: data?.group.map(convertObjectPropsToCamelCase), - user: data?.user.map(convertObjectPropsToCamelCase), + groupNamespaces: data?.group?.map(convertObjectPropsToCamelCase) || [], + userNamespaces: data?.user?.map(convertObjectPropsToCamelCase) || [], }; }; @@ -35,7 +39,7 @@ export default () => { props: { confirmButtonText, confirmationPhrase, - namespaces: prepareNamespaces(namespaces), + ...prepareNamespaces(namespaces), }, on: { selectNamespace: (id) => { |