diff --git a/ghost/admin/app/components/gh-member-label-input.hbs b/ghost/admin/app/components/gh-member-label-input.hbs index 1f9e499767..acf4c20982 100644 --- a/ghost/admin/app/components/gh-member-label-input.hbs +++ b/ghost/admin/app/components/gh-member-label-input.hbs @@ -2,11 +2,12 @@ @extra={{hash tokenComponent="gh-token-input/label-token" }} - @onChange={{action "updateLabels"}} - @onCreate={{action "createLabel"}} + @onChange={{this.updateLabels}} + @onCreate={{this.createLabel}} @options={{this.availableLabels}} @renderInPlace={{true}} - @selected={{selectedLabels}} - @showCreateWhen={{action "hideCreateOptionOnMatchingLabel"}} + @selected={{this.selectedLabels}} + @showCreateWhen={{this.hideCreateOptionOnMatchingLabel}} @triggerId={{this.triggerId}} + @disabled={{@disabled}} /> diff --git a/ghost/admin/app/components/gh-member-label-input.js b/ghost/admin/app/components/gh-member-label-input.js index 14c507fa41..3296a1b529 100644 --- a/ghost/admin/app/components/gh-member-label-input.js +++ b/ghost/admin/app/components/gh-member-label-input.js @@ -1,100 +1,91 @@ -import Component from '@ember/component'; -import {computed} from '@ember/object'; +import Component from '@glimmer/component'; +import {action} from '@ember/object'; import {inject as service} from '@ember/service'; -import {sort} from '@ember/object/computed'; +import {tracked} from '@glimmer/tracking'; -export default Component.extend({ +export default class GhMemberLabelInput extends Component { + @service + store; - store: service(), + @tracked + selectedLabels = []; - // public attrs - member: null, - labelName: '', + get availableLabels() { + return this._availableLabels.toArray().sort((labelA, labelB) => { + return labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true}); + }); + } - // internal attrs - _availableLabels: null, - - selectedLabels: computed.reads('member.labels'), - - availableLabels: sort('_availableLabels.[]', function (labelA, labelB) { - // ignorePunctuation means the # in label names is ignored - return labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true}); - }), - - availableLabelNames: computed('availableLabels.@each.name', function () { - return this.availableLabels.map(label => label.name.toLowerCase()); - }), - - init() { - this._super(...arguments); + constructor(...args) { + super(...args); // perform a background query to fetch all users and set `availableLabels` // to a live-query that will be immediately populated with what's in the // store and be updated when the above query returns this.store.query('label', {limit: 'all'}); - this.set('_availableLabels', this.store.peekAll('label')); - }, + this._availableLabels = this.store.peekAll('label'); + this.selectedLabels = this.args.labels || []; + } - willDestroyElement() { - // NOTE: cleans up labels store in case they were not persisted, this avoids unsaved labels - // from appearing on different input instances when unsaved - this.get('_availableLabels').forEach((label) => { + get availableLabelNames() { + return this.availableLabels.map(label => label.name.toLowerCase()); + } + + willDestroy() { + this._availableLabels.forEach((label) => { if (label.get('isNew')) { this.store.deleteRecord(label); } }); - }, + } - actions: { - matchLabels(labelName, term) { - return labelName.toLowerCase() === term.trim().toLowerCase(); - }, + @action + hideCreateOptionOnMatchingLabel(term) { + return !this.availableLabelNames.includes(term.toLowerCase()); + } - hideCreateOptionOnMatchingLabel(term) { - return !this.availableLabelNames.includes(term.toLowerCase()); - }, + @action + updateLabels(newLabels) { + let currentLabels = this.selectedLabels; - updateLabels(newLabels) { - let currentLabels = this.get('member.labels'); - - // destroy new+unsaved labels that are no longer selected - currentLabels.forEach(function (label) { - if (!newLabels.includes(label) && label.get('isNew')) { - label.destroyRecord(); - } - }); - - // update labels - return this.set('member.labels', newLabels); - }, - - createLabel(labelName) { - let currentLabels = this.get('member.labels'); - let currentLabelNames = currentLabels.map(label => label.get('name').toLowerCase()); - let labelToAdd; - - labelName = labelName.trim(); - - // abort if label is already selected - if (currentLabelNames.includes(labelName.toLowerCase())) { - return; + // destroy new+unsaved labels that are no longer selected + currentLabels.forEach(function (label) { + if (!newLabels.includes(label) && label.get('isNew')) { + label.destroyRecord(); } + }); - // find existing label if there is one - labelToAdd = this._findLabelByName(labelName); + // update labels + this.selectedLabels = newLabels; + this.args.onChange(newLabels); + } - // create new label if no match - if (!labelToAdd) { - labelToAdd = this.store.createRecord('label', { - name: labelName - }); - } + @action + createLabel(labelName) { + let currentLabels = this.selectedLabels; + let currentLabelNames = currentLabels.map(label => label.get('name').toLowerCase()); + let labelToAdd; - // push label onto member relationship - return currentLabels.pushObject(labelToAdd); + labelName = labelName.trim(); + + // abort if label is already selected + if (currentLabelNames.includes(labelName.toLowerCase())) { + return; } - }, - // methods + // find existing label if there is one + labelToAdd = this._findLabelByName(labelName); + + // create new label if no match + if (!labelToAdd) { + labelToAdd = this.store.createRecord('label', { + name: labelName + }); + } + + // push label onto member relationship + currentLabels.pushObject(labelToAdd); + this.args.onChange(currentLabels); + } _findLabelByName(name) { let withMatchingName = function (label) { @@ -102,4 +93,4 @@ export default Component.extend({ }; return this.availableLabels.find(withMatchingName); } -}); +} diff --git a/ghost/admin/app/components/gh-member-settings-form.hbs b/ghost/admin/app/components/gh-member-settings-form.hbs index 518ea35fe8..4393edd90f 100644 --- a/ghost/admin/app/components/gh-member-settings-form.hbs +++ b/ghost/admin/app/components/gh-member-settings-form.hbs @@ -101,7 +101,7 @@
- + diff --git a/ghost/admin/app/components/gh-member-settings-form.js b/ghost/admin/app/components/gh-member-settings-form.js index 73c60f6869..2afe515c57 100644 --- a/ghost/admin/app/components/gh-member-settings-form.js +++ b/ghost/admin/app/components/gh-member-settings-form.js @@ -56,6 +56,9 @@ export default Component.extend({ actions: { setProperty(property, value) { this.setProperty(property, value); + }, + setLabels(labels) { + this.member.set('labels', labels); } }, diff --git a/ghost/admin/app/components/gh-members-import-mapping-input.hbs b/ghost/admin/app/components/gh-members-import-mapping-input.hbs index b787b270ac..2f064f44f5 100644 --- a/ghost/admin/app/components/gh-members-import-mapping-input.hbs +++ b/ghost/admin/app/components/gh-members-import-mapping-input.hbs @@ -1,4 +1,4 @@ - + {{svg-jar "arrow-down-small"}} \ No newline at end of file diff --git a/ghost/admin/app/components/gh-members-import-table.hbs b/ghost/admin/app/components/gh-members-import-table.hbs index fa79b57a80..0eac9466b2 100644 --- a/ghost/admin/app/components/gh-members-import-table.hbs +++ b/ghost/admin/app/components/gh-members-import-table.hbs @@ -19,7 +19,7 @@ {{row.key}} {{row.value}} - + {{else}} diff --git a/ghost/admin/app/components/gh-members-import-table.js b/ghost/admin/app/components/gh-members-import-table.js index b658948239..2e09496bf8 100644 --- a/ghost/admin/app/components/gh-members-import-table.js +++ b/ghost/admin/app/components/gh-members-import-table.js @@ -1,21 +1,77 @@ import Component from '@glimmer/component'; import {action} from '@ember/object'; +import {run} from '@ember/runloop'; +import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; +class MembersFieldMapping { + @tracked _mapping = {}; + + constructor(mapping) { + if (mapping) { + for (const [key, value] of Object.entries(mapping)) { + this._mapping[value] = key; + } + } + } + + get(key) { + return this._mapping[key]; + } + + toJSON() { + return this._mapping; + } + + getKeyByValue(searchedValue) { + for (const [key, value] of Object.entries(this._mapping)) { + if (value === searchedValue) { + return key; + } + } + + return null; + } + + updateMapping(from, to) { + for (const key in this._mapping) { + if (this.get(key) === to) { + this._mapping[key] = null; + } + } + + this._mapping[from] = to; + + // trigger an update + // eslint-disable-next-line no-self-assign + this._mapping = this._mapping; + } +} + export default class GhMembersImportTable extends Component { @tracked dataPreviewIndex = 0; + @service memberImportValidator; + + constructor(...args) { + super(...args); + const mapping = this.memberImportValidator.check(this.args.data); + this.data = this.args.data; + this.mapping = new MembersFieldMapping(mapping); + run.schedule('afterRender', () => this.args.setMapping(this.mapping)); + } + get currentlyDisplayedData() { let rows = []; - if (this.args && this.args.importData && this.args.mapping && this.args.mapping.mapping) { - let currentRecord = this.args.importData[this.dataPreviewIndex]; + if (this.data && this.data.length && this.mapping) { + let currentRecord = this.data[this.dataPreviewIndex]; for (const [key, value] of Object.entries(currentRecord)) { rows.push({ key: key, value: value, - mapTo: this.args.mapping.get(key) + mapTo: this.mapping.get(key) }); } } @@ -24,11 +80,11 @@ export default class GhMembersImportTable extends Component { } get hasNextRecord() { - return this.args.importData && !!(this.args.importData[this.dataPreviewIndex + 1]); + return this.data && !!(this.data[this.dataPreviewIndex + 1]); } get hasPrevRecord() { - return this.args.importData && !!(this.args.importData[this.dataPreviewIndex - 1]); + return this.data && !!(this.data[this.dataPreviewIndex - 1]); } get currentRecord() { @@ -36,8 +92,8 @@ export default class GhMembersImportTable extends Component { } get allRecords() { - if (this.args.importData) { - return this.args.importData.length; + if (this.data) { + return this.data; } else { return 0; } @@ -45,24 +101,21 @@ export default class GhMembersImportTable extends Component { @action updateMapping(mapFrom, mapTo) { - this.args.updateMapping(mapFrom, mapTo); + this.mapping.updateMapping(mapFrom, mapTo); + this.args.setMapping(this.mapping); } @action next() { - const nextValue = this.dataPreviewIndex + 1; - - if (this.args.importData[nextValue]) { - this.dataPreviewIndex = nextValue; + if (this.hasNextRecord) { + this.dataPreviewIndex += 1; } } @action prev() { - const nextValue = this.dataPreviewIndex - 1; - - if (this.args.importData[nextValue]) { - this.dataPreviewIndex = nextValue; + if (this.hasPrevRecord) { + this.dataPreviewIndex -= 1; } } } diff --git a/ghost/admin/app/components/modal-import-members.hbs b/ghost/admin/app/components/modal-import-members.hbs index d9555ff419..6194341679 100644 --- a/ghost/admin/app/components/modal-import-members.hbs +++ b/ghost/admin/app/components/modal-import-members.hbs @@ -1,179 +1,161 @@ - -{{svg-jar "close"}} +

Import complete

+ + {{/if}} - +
\ No newline at end of file diff --git a/ghost/admin/app/components/modal-import-members.js b/ghost/admin/app/components/modal-import-members.js index 7f341d9c7e..6964376feb 100644 --- a/ghost/admin/app/components/modal-import-members.js +++ b/ghost/admin/app/components/modal-import-members.js @@ -1,252 +1,96 @@ import ModalComponent from 'ghost-admin/components/modal-base'; import ghostPaths from 'ghost-admin/utils/ghost-paths'; -import papaparse from 'papaparse'; +import moment from 'moment'; +import unparse from '@tryghost/members-csv/lib/unparse'; import { - UnsupportedMediaTypeError, + AcceptedResponse, isRequestEntityTooLargeError, isUnsupportedMediaTypeError, isVersionMismatchError } from 'ghost-admin/services/ajax'; -// eslint-disable-next-line ghost/ember/no-computed-properties-in-native-classes import {computed} from '@ember/object'; import {htmlSafe} from '@ember/string'; import {isBlank} from '@ember/utils'; import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -class MembersFieldMapping { - @tracked _mapping = {}; - - constructor(mapping) { - if (mapping) { - for (const [key, value] of Object.entries(mapping)) { - this.set(value, key); - } - } - } - - set(key, value) { - this._mapping[key] = value; - - // trigger an update - // eslint-disable-next-line no-self-assign - this._mapping = this._mapping; - } - - get(key) { - return this._mapping[key]; - } - - get mapping() { - return this._mapping; - } - - getKeyByValue(searchedValue) { - for (const [key, value] of Object.entries(this._mapping)) { - if (value === searchedValue) { - return key; - } - } - - return null; - } - - updateMapping(from, to) { - for (const key in this._mapping) { - if (this.get(key) === to) { - this.set(key, null); - } - } - - this.set(from, to); - } -} export default ModalComponent.extend({ config: service(), ajax: service(), notifications: service(), - memberImportValidator: service(), store: service(), - labelText: 'Select or drop a CSV file', + state: 'INIT', - // import stages, default is "CSV file selection" - validating: false, - customizing: false, - uploading: false, - summary: false, - - dragClass: null, file: null, - fileData: null, - mapping: null, + mappingResult: null, paramName: 'membersfile', importResponse: null, - failureMessage: null, - validationErrors: null, - uploadErrors: null, - labels: null, + errorMessage: null, + showMappingErrors: false, // Allowed actions confirm: () => {}, - filePresent: computed.reads('file'), - closeDisabled: computed.reads('uploading'), - uploadUrl: computed(function () { return `${ghostPaths().apiRoot}/members/upload/`; }), - importDisabled: computed('file', 'validationErrors', function () { - const hasEmptyDataFile = this.validationErrors && this.validationErrors.filter(error => error.message.includes('File is empty')).length; - return !this.file || !(this._validateFileType(this.file)) || hasEmptyDataFile; - }), - formData: computed('file', function () { - let paramName = this.paramName; - let file = this.file; let formData = new FormData(); - formData.append(paramName, file); + formData.append(this.paramName, this.file); - if (this.labels.labels.length) { - this.labels.labels.forEach((label) => { + if (this.mappingResult.labels) { + this.mappingResult.labels.forEach((label) => { formData.append('labels', label.name); }); } - if (this.mapping) { - for (const key in this.mapping.mapping) { - if (this.mapping.get(key)){ - // reversing mapping direction to match the structure accepted in the API - formData.append(`mapping[${this.mapping.get(key)}]`, key); - } + if (this.mappingResult.mapping) { + let mapping = this.mappingResult.mapping.toJSON(); + for (let [key, val] of Object.entries(mapping)) { + formData.append(`mapping[${key}]`, val); } } return formData; }), - init() { - this._super(...arguments); - - // NOTE: nested label come from specific "gh-member-label-input" parameters, would be good to refactor - this.labels = {labels: []}; - }, - actions: { - fileSelected(fileList) { - let [file] = Array.from(fileList); - let validationResult = this._validateFileType(file); + setFile(file) { + this.set('file', file); + this.set('state', 'MAPPING'); + }, - if (validationResult !== true) { - this._validationFailed(validationResult); + setMappingResult(mappingResult) { + this.set('mappingResult', mappingResult); + }, + + upload() { + if (this.file && !this.mappingResult.error) { + this.generateRequest(); + this.set('showMappingErrors', false); } else { - this.set('file', file); - this.set('failureMessage', null); - - this.set('validating', true); - - papaparse.parse(file, { - header: true, - skipEmptyLines: true, - worker: true, // NOTE: compare speed and file sizes with/without this flag - complete: async (results) => { - this.set('fileData', results.data); - - let {validationErrors, mapping} = await this.memberImportValidator.check(results.data); - this.set('mapping', new MembersFieldMapping(mapping)); - - if (validationErrors.length) { - this._importValidationFailed(validationErrors); - } else { - this.set('validating', false); - this.set('customizing', true); - } - }, - error: (error) => { - this._validationFailed(error); - } - }); + this.set('showMappingErrors', true); } }, reset() { - this.set('failureMessage', null); - this.set('labels', {labels: []}); + this.set('showMappingErrors', false); + this.set('errorMessage', null); this.set('file', null); - this.set('fileData', null); this.set('mapping', null); - this.set('validationErrors', null); - this.set('uploadErrors', null); - - this.set('validating', false); - this.set('customizing', false); - this.set('uploading', false); - this.set('summary', false); - }, - - upload() { - if (this.file && this.mapping.getKeyByValue('email')) { - this.generateRequest(); - } else { - this.set('uploadErrors', [{ - message: 'Import as "Email" value is missing.', - context: 'The CSV import has to have selected import as "Email" field.' - }]); - } - }, - - continueImport() { - this.set('validating', false); - this.set('customizing', true); - }, - - confirm() { - // noop - we don't want the enter key doing anything + this.set('state', 'INIT'); }, closeModal() { - if (!this.closeDisabled) { + if (this.state !== 'UPLOADING') { this._super(...arguments); } }, - updateMapping(mapFrom, mapTo) { - this.mapping.updateMapping(mapFrom, mapTo); - } - }, - - dragOver(event) { - if (!event.dataTransfer) { - return; - } - - // this is needed to work around inconsistencies with dropping files - // from Chrome's downloads bar - if (navigator.userAgent.indexOf('Chrome') > -1) { - let eA = event.dataTransfer.effectAllowed; - event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy'; - } - - event.stopPropagation(); - event.preventDefault(); - - this.set('dragClass', '-drag-over'); - }, - - dragLeave(event) { - event.preventDefault(); - this.set('dragClass', null); - }, - - drop(event) { - event.preventDefault(); - this.set('dragClass', null); - if (event.dataTransfer.files) { - this.send('fileSelected', event.dataTransfer.files); - } + // noop - we don't want the enter key doing anything + confirm() {} }, generateRequest() { @@ -254,38 +98,81 @@ export default ModalComponent.extend({ let formData = this.formData; let url = this.uploadUrl; - this._uploadStarted(); + this.set('state', 'UPLOADING'); ajax.post(url, { data: formData, processData: false, contentType: false, dataType: 'text' }).then((importResponse) => { - this._uploadSuccess(JSON.parse(importResponse)); + if (importResponse instanceof AcceptedResponse) { + this.set('state', 'PROCESSING'); + } else { + this._uploadSuccess(JSON.parse(importResponse)); + this.set('state', 'COMPLETE'); + } }).catch((error) => { - this._validationFailed(error); - }).finally(() => { - this._uploadFinished(); + this._uploadError(error); + this.set('state', 'ERROR'); }); }, - _uploadStarted() { - this.set('customizing', false); - this.set('uploading', true); - }, - _uploadSuccess(importResponse) { - if (importResponse.meta.stats.invalid && importResponse.meta.stats.invalid.errors) { - importResponse.meta.stats.invalid.errors.forEach((error) => { - if (error.message === 'Value in [members.email] cannot be blank.') { - error.message = 'Missing email address'; - } else if (error.message === 'Validation (isEmail) failed for email') { - error.message = 'Invalid email address'; + let importedCount = importResponse.meta.stats.imported; + const erroredMembers = importResponse.meta.stats.invalid; + let errorCount = erroredMembers.length; + const errorList = {}; + + const errorsWithFormattedMessages = erroredMembers.map((row) => { + const formattedError = row.error + .replace( + 'Value in [members.email] cannot be blank.', + 'Missing email address' + ) + .replace( + 'Value in [members.note] exceeds maximum length of 2000 characters.', + '"Note" exceeds maximum length of 2000 characters' + ) + .replace( + 'Value in [members.subscribed] must be one of true, false, 0 or 1.', + 'Value in "Subscribed to emails" must be "true" or "false"' + ) + .replace( + 'Validation (isEmail) failed for email', + 'Invalid email address' + ) + .replace( + /No such customer:[^,]*/, + 'Could not find Stripe customer' + ); + formattedError.split(',').forEach((errorMssg) => { + if (errorList[errorMssg]) { + errorList[errorMssg].count = errorList[errorMssg].count + 1; + } else { + errorList[errorMssg] = { + message: errorMssg, + count: 1 + }; } }); - } + return { + ...row, + error: formattedError + }; + }); - this.set('importResponse', importResponse.meta.stats); + let errorCsv = unparse(errorsWithFormattedMessages); + let errorCsvBlob = new Blob([errorCsv], {type: 'text/csv'}); + let errorCsvUrl = URL.createObjectURL(errorCsvBlob); + let errorCsvName = importResponse.meta.import_label ? `${importResponse.meta.import_label.name} - Errors.csv` : `Import ${moment().format('YYYY-MM-DD HH:mm')} - Errors.csv`; + + this.set('importResponse', { + importedCount, + errorCount, + errorCsvUrl, + errorCsvName, + errorList: Object.values(errorList) + }); // insert auto-created import label into store immediately if present // ready for filtering the members list @@ -296,22 +183,11 @@ export default ModalComponent.extend({ } // invoke the passed in confirm action to refresh member data + // @TODO wtf does confirm mean? this.confirm({label: importResponse.meta.import_label}); }, - _uploadFinished() { - this.set('uploading', false); - - if (!this.get('failureMessage')) { - this.set('summary', true); - } - }, - - _importValidationFailed(errors) { - this.set('validationErrors', errors); - }, - - _validationFailed(error) { + _uploadError(error) { let message; if (isVersionMismatchError(error)) { @@ -329,16 +205,6 @@ export default ModalComponent.extend({ message = 'Something went wrong :('; } - this.set('failureMessage', message); - }, - - _validateFileType(file) { - let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); - - if (['csv'].indexOf(extension.toLowerCase()) === -1) { - return new UnsupportedMediaTypeError(); - } - - return true; + this.set('errorMessage', message); } }); diff --git a/ghost/admin/app/components/modal-import-members/csv-file-mapping.hbs b/ghost/admin/app/components/modal-import-members/csv-file-mapping.hbs new file mode 100644 index 0000000000..75ed26d868 --- /dev/null +++ b/ghost/admin/app/components/modal-import-members/csv-file-mapping.hbs @@ -0,0 +1,25 @@ +{{#if this.hasFileData}} + +
+
+ +
+
+ {{#if (and this.error @showErrors)}} +

{{this.error.message}}

+ {{/if}} +

If an email address in your CSV matches an existing member, they will be updated with the mapped values.

+ +
+ + +
+
+{{else}} +
+ +
+{{/if}} diff --git a/ghost/admin/app/components/modal-import-members/csv-file-mapping.js b/ghost/admin/app/components/modal-import-members/csv-file-mapping.js new file mode 100644 index 0000000000..ce9f484253 --- /dev/null +++ b/ghost/admin/app/components/modal-import-members/csv-file-mapping.js @@ -0,0 +1,72 @@ +import Component from '@glimmer/component'; +import MemberImportError from 'ghost-admin/errors/member-import-error'; +import papaparse from 'papaparse'; +import {action} from '@ember/object'; +import {isNone} from '@ember/utils'; +import {tracked} from '@glimmer/tracking'; + +export default class CsvFileMapping extends Component { + @tracked + error = null; + + @tracked + fileData = null; + + mappingResult = {}; + + constructor(...args) { + super(...args); + this.parseFileAndGenerateMapping(this.args.file); + } + + parseFileAndGenerateMapping(file) { + papaparse.parse(file, { + header: true, + skipEmptyLines: true, + complete: (result) => { + if (result.data && result.data.length) { + this.fileData = result.data; + } else { + this.fileData = []; + } + } + }); + } + + get hasFileData() { + return !isNone(this.fileData); + } + + @action + setMapping(mapping) { + if (this.fileData.length === 0) { + this.error = new MemberImportError({ + message: 'File is empty, nothing to import. Please select a different file.' + }); + } else if (!mapping.getKeyByValue('email')) { + this.error = new MemberImportError({ + message: 'Please map "Email" to one of the fields in the CSV.' + }); + } else { + this.error = null; + } + + this.mapping = mapping; + this.setMappingResult(); + } + + @action + updateLabels(labels) { + this.labels = labels; + this.setMappingResult(); + } + + setMappingResult() { + this.args.setMappingResult({ + mapping: this.mapping, + labels: this.labels, + membersCount: this.fileData?.length, + error: this.error + }); + } +} diff --git a/ghost/admin/app/components/modal-import-members/csv-file-select.hbs b/ghost/admin/app/components/modal-import-members/csv-file-select.hbs new file mode 100644 index 0000000000..e13ab4d9e9 --- /dev/null +++ b/ghost/admin/app/components/modal-import-members/csv-file-select.hbs @@ -0,0 +1,20 @@ +{{#if this.error}} +
+
{{svg-jar "warning" class="nudge-top--2 w4 h4 fill-red"}}
+

{{this.error.message}}

+
+{{/if}} +
+
+ +
+ {{svg-jar "upload" class="w9 h9 mb1 stroke-midgrey"}} +
{{this.labelText}}
+
+
+
+
diff --git a/ghost/admin/app/components/modal-import-members/csv-file-select.js b/ghost/admin/app/components/modal-import-members/csv-file-select.js new file mode 100644 index 0000000000..ac672fa1d5 --- /dev/null +++ b/ghost/admin/app/components/modal-import-members/csv-file-select.js @@ -0,0 +1,81 @@ +import Component from '@glimmer/component'; +import {UnsupportedMediaTypeError} from 'ghost-admin/services/ajax'; +import {action} from '@ember/object'; +import {tracked} from '@glimmer/tracking'; + +export default class CsvFileSelect extends Component { + labelText = 'Select or drop a CSV file' + + @tracked + error = null + @tracked + dragClass = null + + /* + constructor(...args) { + super(...args); + assert(this.args.setFile); + } + */ + + @action + fileSelected(fileList) { + let [file] = Array.from(fileList); + + try { + this._validateFileType(file); + this.error = null; + } catch (err) { + this.error = err; + return; + } + + this.args.setFile(file); + } + + @action + dragOver(event) { + if (!event.dataTransfer) { + return; + } + + // this is needed to work around inconsistencies with dropping files + // from Chrome's downloads bar + if (navigator.userAgent.indexOf('Chrome') > -1) { + let eA = event.dataTransfer.effectAllowed; + event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy'; + } + + event.stopPropagation(); + event.preventDefault(); + + this.dragClass = '-drag-over'; + } + + @action + dragLeave(event) { + event.preventDefault(); + this.dragClass = null; + } + + @action + drop(event) { + event.preventDefault(); + this.dragClass = null; + if (event.dataTransfer.files) { + this.fileSelected(event.dataTransfer.files); + } + } + + _validateFileType(file) { + let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); + + if (extension.toLowerCase() !== 'csv') { + throw new UnsupportedMediaTypeError({ + message: 'The file type you uploaded is not supported' + }); + } + + return true; + } +} diff --git a/ghost/admin/app/services/ajax.js b/ghost/admin/app/services/ajax.js index 162d6b0fac..e9d1888732 100644 --- a/ghost/admin/app/services/ajax.js +++ b/ghost/admin/app/services/ajax.js @@ -144,6 +144,19 @@ export function isEmailError(errorOrStatus, payload) { /* end: custom error types */ +export class AcceptedResponse { + constructor(data) { + this.data = data; + } +} + +export function isAcceptedResponse(errorOrStatus) { + if (errorOrStatus === 202) { + return true; + } + return false; +} + let ajaxService = AjaxService.extend({ session: service(), @@ -198,6 +211,8 @@ let ajaxService = AjaxService.extend({ return new HostLimitError(payload); } else if (this.isEmailError(status, headers, payload)) { return new EmailError(payload); + } else if (this.isAcceptedResponse(status)) { + return new AcceptedResponse(payload); } let isGhostRequest = GHOST_REQUEST.test(request.url); @@ -264,6 +279,10 @@ let ajaxService = AjaxService.extend({ isEmailError(status, headers, payload) { return isEmailError(status, payload); + }, + + isAcceptedResponse(status) { + return isAcceptedResponse(status); } }); diff --git a/ghost/admin/app/services/member-import-validator.js b/ghost/admin/app/services/member-import-validator.js index 4aed0ff09c..0e1ac3a3f8 100644 --- a/ghost/admin/app/services/member-import-validator.js +++ b/ghost/admin/app/services/member-import-validator.js @@ -1,8 +1,5 @@ -import MemberImportError from 'ghost-admin/errors/member-import-error'; import Service, {inject as service} from '@ember/service'; import validator from 'validator'; -import {formatNumber} from 'ghost-admin/helpers/format-number'; -import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize'; import {isEmpty} from '@ember/utils'; export default Service.extend({ @@ -10,69 +7,10 @@ export default Service.extend({ membersUtils: service(), ghostPaths: service(), - async check(data) { - if (!data || !data.length) { - return { - validationErrors: [new MemberImportError({ - message: 'File is empty, nothing to import. Please select a different file.' - })] - }; - } - + check(data) { let sampledData = this._sampleData(data); let mapping = this._detectDataTypes(sampledData); - - let validationErrors = []; - - const hasStripeIds = !!mapping.stripe_customer_id; - const hasEmails = !!mapping.email; - - if (hasStripeIds) { - // check can be done on whole set as it won't be too slow - const {totalCount, duplicateCount} = this._checkStripeIds(data, mapping); - - if (!this.membersUtils.isStripeEnabled) { - validationErrors.push(new MemberImportError({ - message: `Missing Stripe connection`, - context: `${ghPluralize(totalCount, 'Stripe customer')} won't be imported. You need to connect to Stripe to import Stripe customers.`, - type: 'warning' - })); - } else { - let stripeSeverValidation = await this._checkStripeServer(sampledData, mapping); - if (stripeSeverValidation !== true) { - validationErrors.push(new MemberImportError({ - message: 'Wrong Stripe account', - context: `The CSV contains Stripe customers from a different Stripe account. These members will not be imported. Make sure you're connected to the correct Stripe account.`, - type: 'warning' - })); - } - } - - if (duplicateCount) { - validationErrors.push(new MemberImportError({ - message: `Duplicate Stripe ID (${formatNumber(duplicateCount)})`, - type: 'warning' - })); - } - } - - if (!hasEmails) { - validationErrors.push(new MemberImportError({ - message: 'No email addresses found in the uploaded CSV.' - })); - } else { - // check can be done on whole set as it won't be too slow - const {emptyCount} = this._checkEmails(data, mapping); - - if (emptyCount) { - validationErrors.push(new MemberImportError({ - message: `Missing email address (${formatNumber(emptyCount)})`, - type: 'warning' - })); - } - } - - return {validationErrors, mapping}; + return mapping; }, /** @@ -141,15 +79,12 @@ export default Service.extend({ 'name', 'note', 'subscribed_to_emails', - 'stripe_customer_id', - 'complimentary_plan', 'labels', 'created_at' ]; const autoDetectedTypes = [ - 'email', - 'stripe_customer_id' + 'email' ]; let mapping = {}; @@ -167,11 +102,6 @@ export default Service.extend({ continue; } - if (!mapping.stripe_customer_id && value && value.startsWith && value.startsWith('cus_')) { - mapping.stripe_customer_id = key; - continue; - } - if (!mapping.name && /name/.test(key)) { mapping.name = key; continue; @@ -186,99 +116,5 @@ export default Service.extend({ } return mapping; - }, - - _containsRecordsWithStripeId(validatedSet) { - let memberWithStripeId = validatedSet.find(m => !!(m.stripe_customer_id)); - return !!memberWithStripeId; - }, - - _checkEmails(validatedSet, mapping) { - let emptyCount = 0; - - validatedSet.forEach((member) => { - let emailValue = member[mapping.email]; - if (!emailValue) { - emptyCount += 1; - } - }); - - return {emptyCount}; - }, - - _countStripeRecors(validatedSet, mapping) { - let count = 0; - - validatedSet.forEach((member) => { - if (!isEmpty(member[mapping.stripe_customer_id])) { - count += 1; - } - }); - - return count; - }, - - _checkStripeIds(validatedSet, mapping) { - let totalCount = 0; - let duplicateCount = 0; - - validatedSet.reduce((acc, member) => { - let stripeCustomerIdValue = member[mapping.stripe_customer_id]; - - if (stripeCustomerIdValue && stripeCustomerIdValue !== 'undefined') { - totalCount += 1; - - if (acc[stripeCustomerIdValue]) { - acc[stripeCustomerIdValue] += 1; - duplicateCount += 1; - } else { - acc[stripeCustomerIdValue] = 1; - } - } - - return acc; - }, {}); - - return {totalCount, duplicateCount}; - }, - - _checkContainsStripeIDs(validatedSet) { - let result = true; - - if (!this.membersUtils.isStripeEnabled) { - validatedSet.forEach((member) => { - if (member.stripe_customer_id) { - result = false; - } - }); - } - - return result; - }, - - async _checkStripeServer(validatedSet, mapping) { - const url = this.ghostPaths.get('url').api('members/upload/validate'); - const mappedValidatedSet = validatedSet.map((entry) => { - return { - stripe_customer_id: entry[mapping.stripe_customer_id] - }; - }); - - let response; - try { - response = await this.ajax.post(url, { - data: { - members: mappedValidatedSet - } - }); - } catch (e) { - return false; - } - - if (response.errors) { - return false; - } - - return true; } }); diff --git a/ghost/admin/app/styles/components/modals.css b/ghost/admin/app/styles/components/modals.css index 32362781d3..90aa9b3629 100644 --- a/ghost/admin/app/styles/components/modals.css +++ b/ghost/admin/app/styles/components/modals.css @@ -131,6 +131,31 @@ letter-spacing: 0.2px; } +.modal-header.icon-center { + padding-top: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + min-height: 124px; +} + +.modal-header.icon-center svg { + width: 66px; + height: 66px; +} + +.modal-header.icon-center h1 { + margin: 20px 0 8px; + padding: 0; +} + +.modal-header.icon-center .gh-loading-content { + position: relative; + padding: 8px 0; + height: 62px; +} + .modal-body { position: relative; } diff --git a/ghost/admin/app/styles/layouts/members.css b/ghost/admin/app/styles/layouts/members.css index 4f1a0aab5a..0d03a5f1a3 100644 --- a/ghost/admin/app/styles/layouts/members.css +++ b/ghost/admin/app/styles/layouts/members.css @@ -527,7 +527,39 @@ textarea.gh-member-details-textarea { /* ---------------------------------------------------------- */ .fullscreen-modal-import-members { - max-width: 640px; + max-width: unset !important; +} + +.gh-member-import-wrapper { + width: 420px; +} + +.gh-member-import-wrapper.wide { + width: 580px; +} + +.gh-member-import-wrapper .gh-btn.disabled, +.gh-member-import-wrapper .gh-btn.disabled:hover { + cursor: auto !important; + opacity: 0.6 !important; +} + +.gh-member-import-wrapper .gh-btn.disabled span, +.gh-member-import-wrapper .gh-btn.disabled span:hover { + cursor: auto !important; + pointer-events: none; +} + +.gh-member-import-wrapper .gh-token-input .ember-power-select-trigger[aria-disabled=true], +.gh-member-import-wrapper .gh-token-input .ember-power-select-trigger-multiple-input:disabled { + background: var(--whitegrey-l2); +} + +@media (max-width: 600px) { + .gh-member-import-wrapper, + .gh-member-import-wrapper.wide { + width: calc(100vw - 128px); + } } .gh-members-import-uploader { @@ -545,6 +577,7 @@ textarea.gh-member-details-textarea { min-height: 182px; justify-content: center; align-items: center; + margin-bottom: -20px; } .gh-members-import-spinner .gh-loading-content { @@ -595,6 +628,27 @@ p.gh-members-import-errorcontext { font-weight: 400; } +.gh-members-import-mapping .error { + color: var(--red); +} + +.gh-members-import-mappingwrapper.error { + position: relative; +} + +.gh-members-import-mappingwrapper.error::before { + display: block; + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 1px solid red; + z-index: 9999; + pointer-events: none; +} + .gh-members-import-scrollarea { position: relative; max-height: calc(100vh - 350px - 12vw); @@ -778,8 +832,12 @@ p.gh-members-import-errordetail:first-of-type { overflow-wrap: break-word; } +.gh-member-import-resultcontainer { + margin-bottom: 28px; +} + .gh-member-import-result-summary { - padding: 20px; + flex-basis: 50%; } .gh-member-import-result-summary h2 { @@ -790,9 +848,42 @@ p.gh-members-import-errordetail:first-of-type { } .gh-member-import-result-summary p { - margin: -2px 0 0; + color: var(--darkgrey); + margin: 0; padding: 0; - color: var(--midlightgrey); + line-height: 1.6em; + margin-bottom: 12px; +} + +.gh-member-import-result-summary p strong { + font-size: 1.5rem; +} + +.gh-member-import-errorlist { + width: 100%; + margin: 8px 0 28px; +} + +.gh-member-import-errorlist h4 { + font-size: 13px; + font-weight: 500; + border-bottom: 1px solid var(--whitegrey); + padding-bottom: 8px; + margin-top: 0px; + color: var(--midgrey); +} + +.gh-member-import-errorlist p { + font-size: 13px; + font-weight: 400; + color: var(--midlightgrey-d2); + padding: 0; + margin-bottom: 6px; +} + +.gh-member-import-resultcontainer hr { + margin: 24px -32px; + border-color: var(--whitegrey); } .gh-member-import-nodata span { @@ -803,6 +894,26 @@ p.gh-members-import-errordetail:first-of-type { color: var(--midgrey); } +.gh-member-import-icon-members { + color: var(--green); +} + +.gh-member-import-icon-members path, +.gh-member-import-icon-members circle { + stroke-width: 0.85px; +} + +.gh-member-import-icon-confetti { + color: var(--purple); + margin-left: 12px; +} + +.gh-member-import-icon-confetti path, +.gh-member-import-icon-confetti circle, +.gh-member-import-icon-confetti ellipse { + stroke-width: 0.85px; +} + /* Fixing Firefox's select padding */ @-moz-document url-prefix() { .gh-import-member-select select { diff --git a/ghost/admin/app/templates/members/import.hbs b/ghost/admin/app/templates/members/import.hbs index fd60487b45..5522be2b22 100644 --- a/ghost/admin/app/templates/members/import.hbs +++ b/ghost/admin/app/templates/members/import.hbs @@ -1,4 +1,4 @@ + @modifier="action import-members" /> diff --git a/ghost/admin/package.json b/ghost/admin/package.json index e2d6ff6d3c..8cc0a5eace 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -33,6 +33,7 @@ "@tryghost/helpers": "1.1.34", "@tryghost/kg-clean-basic-html": "1.0.10", "@tryghost/kg-parser-plugins": "1.0.10", + "@tryghost/members-csv": "0.4.0", "@tryghost/mobiledoc-kit": "0.12.5-ghost.1", "@tryghost/string": "0.1.14", "@tryghost/timezone-data": "0.2.32", diff --git a/ghost/admin/public/assets/icons/confetti.svg b/ghost/admin/public/assets/icons/confetti.svg new file mode 100644 index 0000000000..5477c32deb --- /dev/null +++ b/ghost/admin/public/assets/icons/confetti.svg @@ -0,0 +1 @@ +party-confetti \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-outline.svg b/ghost/admin/public/assets/icons/members-outline.svg new file mode 100644 index 0000000000..c50df7fda0 --- /dev/null +++ b/ghost/admin/public/assets/icons/members-outline.svg @@ -0,0 +1,5 @@ +multiple-users-1 + + + + \ No newline at end of file diff --git a/ghost/admin/tests/integration/components/gh-members-import-table-test.js b/ghost/admin/tests/integration/components/gh-members-import-table-test.js index 7b96dd8187..302de9d5fc 100644 --- a/ghost/admin/tests/integration/components/gh-members-import-table-test.js +++ b/ghost/admin/tests/integration/components/gh-members-import-table-test.js @@ -1,38 +1,21 @@ import hbs from 'htmlbars-inline-precompile'; -import {click, find, findAll, render} from '@ember/test-helpers'; +import {click, findAll, render} from '@ember/test-helpers'; import {describe, it} from 'mocha'; import {expect} from 'chai'; import {setupRenderingTest} from 'ember-mocha'; describe('Integration: Component: gh-members-import-table', function () { setupRenderingTest(); - const mockMapping = { - mapping: {}, - get: (key) => { - return key; - } - }; - - it('renders empty without data', async function () { - await render(hbs` - - `); - - expect(find('table')).to.exist; - expect(findAll('table thead th').length).to.equal(3); - expect(findAll('table tbody tr').length).to.equal(1); - expect(find('table tbody').textContent).to.match(/No data/); - }); it('renders members data with all the properties', async function () { this.set('importData', [{ name: 'Kevin', email: 'kevin@example.com' }]); - this.set('mapping', mockMapping); + this.set('setMapping', () => {}); await render(hbs` - + `); expect(findAll('table tbody tr').length).to.equal(2); @@ -52,10 +35,10 @@ describe('Integration: Component: gh-members-import-table', function () { name: 'Rish', email: 'rish@example.com' }]); - this.set('mapping', mockMapping); + this.set('setMapping', () => {}); await render(hbs` - + `); expect(findAll('table tbody tr').length).to.equal(2); diff --git a/ghost/admin/tests/integration/components/modal-import-members-test.js b/ghost/admin/tests/integration/components/modal-import-members-test.js index 947a67555a..a600738b6d 100644 --- a/ghost/admin/tests/integration/components/modal-import-members-test.js +++ b/ghost/admin/tests/integration/components/modal-import-members-test.js @@ -1,13 +1,11 @@ -import $ from 'jquery'; import Pretender from 'pretender'; import Service from '@ember/service'; import hbs from 'htmlbars-inline-precompile'; import sinon from 'sinon'; -import {click, find, findAll, render, settled, triggerEvent, waitFor} from '@ember/test-helpers'; +import {click, find, findAll, render, waitFor} from '@ember/test-helpers'; import {describe, it} from 'mocha'; import {expect} from 'chai'; import {fileUpload} from '../../helpers/file-upload'; -import {run} from '@ember/runloop'; import {setupRenderingTest} from 'ember-mocha'; const notificationsStub = Service.extend({ @@ -178,34 +176,16 @@ describe('Integration: Component: modal-import-members-test', function () { expect(showAPIError.called).to.be.false; }); - it('handles drag over/leave', async function () { - await render(hbs`{{modal-import-members}}`); - - run(() => { - // eslint-disable-next-line new-cap - let dragover = $.Event('dragover', { - dataTransfer: { - files: [] - } - }); - $(find('.gh-image-uploader')).trigger(dragover); - }); - - await settled(); - - expect(find('.gh-image-uploader').classList.contains('-drag-over'), 'has drag-over class').to.be.true; - - await triggerEvent('.gh-image-uploader', 'dragleave'); - - expect(find('.gh-image-uploader').classList.contains('-drag-over'), 'has drag-over class').to.be.false; - }); - it('validates extension by default', async function () { - stubSuccessfulUpload(server); + stubFailedUpload(server, 415); await render(hbs`{{modal-import-members}}`); - await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.txt'}); + await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); + + // Wait for async CSV parsing to finish + await waitFor('table', {timeout: 50}); + await click('.gh-btn-green'); expect(findAll('.failed').length, 'error message is displayed').to.equal(1); expect(find('.failed').textContent).to.match(/The file type you uploaded is not supported/); diff --git a/ghost/admin/tests/integration/services/member-import-validator-test.js b/ghost/admin/tests/integration/services/member-import-validator-test.js index 5b5de8fd39..2d055fa55c 100644 --- a/ghost/admin/tests/integration/services/member-import-validator-test.js +++ b/ghost/admin/tests/integration/services/member-import-validator-test.js @@ -25,75 +25,14 @@ describe('Integration: Service: member-import-validator', function () { it('checks correct data without Stripe customer', async function () { let service = this.owner.lookup('service:member-import-validator'); - const {validationErrors, mapping} = await service.check([{ + const mapping = await service.check([{ name: 'Rish', email: 'validemail@example.com' }]); - expect(validationErrors.length).to.equal(0); expect(mapping.email).to.equal('email'); }); - it('returns validation error when no data is provided', async function () { - let service = this.owner.lookup('service:member-import-validator'); - - const {validationErrors} = await service.check([]); - - expect(validationErrors.length).to.equal(1); - expect(validationErrors[0].message).to.equal('File is empty, nothing to import. Please select a different file.'); - }); - - it('returns validation error for data with stripe_customer_id but no connected Stripe', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const {validationErrors, mapping} = await service.check([{ - name: 'Kevin', - email: 'goodeamil@example.com', - stripe_customer_id: 'cus_XXXX' - }]); - - expect(validationErrors.length).to.equal(1); - expect(validationErrors[0].message).to.equal('Missing Stripe connection'); - expect(mapping.name).to.equal('name'); - expect(mapping.email).to.equal('email'); - expect(mapping.stripe_customer_id).to.equal('stripe_customer_id'); - }); - - it('returns validation error for no valid emails', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const {validationErrors} = await service.check([{ - email: 'invalid_email' - }]); - - expect(validationErrors.length).to.equal(1); - expect(validationErrors[0].message).to.equal('No email addresses found in the uploaded CSV.'); - }); - - it('ignores validation for invalid email', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const {validationErrors} = await service.check([{ - email: 'invalid_email' - }, { - email: 'email@example.com' - }]); - - expect(validationErrors.length).to.equal(0); - }); - describe('data sampling method', function () { it('returns whole data set when sampled size is less then default 30', async function () { this.owner.register('service:membersUtils', Service.extend({ @@ -205,7 +144,6 @@ describe('Integration: Service: member-import-validator', function () { }]); expect(result.email).to.equal('correo_electronico'); - expect(result.stripe_customer_id).to.equal('stripe_id'); }); it('correctly detects variation of "name" mapping', async function () { diff --git a/ghost/admin/yarn.lock b/ghost/admin/yarn.lock index b4919e2a8a..639da03e91 100644 --- a/ghost/admin/yarn.lock +++ b/ghost/admin/yarn.lock @@ -1514,6 +1514,13 @@ dependencies: "@tryghost/kg-clean-basic-html" "^1.0.10" +"@tryghost/members-csv@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@tryghost/members-csv/-/members-csv-0.4.0.tgz#cd91d1ad4ca3c317cb9c3dbfd27c5414004f901b" + integrity sha512-GotwC8geHK7A8qBrXDNU+P9tIBOCYqwqZp2I/60BYgBJT6wEUpxU8KezluZ6fuHsk1fKIBRUk9k5+qi83sQI3A== + dependencies: + papaparse "5.3.0" + "@tryghost/mobiledoc-kit@0.12.5-ghost.1": version "0.12.5-ghost.1" resolved "https://registry.yarnpkg.com/@tryghost/mobiledoc-kit/-/mobiledoc-kit-0.12.5-ghost.1.tgz#f79b0f9a9b93eb100fd3dc1c02b343d5d334f4e0" @@ -4131,6 +4138,11 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30001164, can resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001165.tgz#32955490d2f60290bb186bb754f2981917fa744f" integrity sha512-8cEsSMwXfx7lWSUMA2s08z9dIgsnR5NAqjXP23stdsU3AUWkCr/rr4s4OFtHXn5XXr6+7kam3QFVoYyXNPdJPA== +caniuse-lite@^1.0.30001161: + version "1.0.30001162" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001162.tgz#9f83aad1f42539ce9aab58bb177598f2f8e22ec6" + integrity sha512-E9FktFxaNnp4ky3ucIGzEXLM+Knzlpuq1oN1sFAU0KeayygabGTmOsndpo8QrL4D9pcThlf4D2pUKaDxPCUmVw== + capture-exit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" @@ -12718,6 +12730,13 @@ semver@^7.0.0, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: dependencies: lru-cache "^6.0.0" +semver@^7.3.4: + version "7.3.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97" + integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw== + dependencies: + lru-cache "^6.0.0" + send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"