mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-24 23:48:13 -05:00
Added separate stage to members import popup with support of labels
refs 633ba27f0e
- Import modal is now devided into separate stages with ability to specify labels which will be assigned to every member present in the dataset.
- Also adds explicit "Import" button without automatic import when the CSV file is selected
This commit is contained in:
parent
1d4a2282d7
commit
6bfe8e6490
3 changed files with 425 additions and 20 deletions
|
@ -3,12 +3,16 @@
|
||||||
{{#if this.response}}
|
{{#if this.response}}
|
||||||
Import result
|
Import result
|
||||||
{{else}}
|
{{else}}
|
||||||
Import members
|
Import {{#if this.filePresent}}<a href="#" {{action "reset"}}><span>(start over)</span></a>{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
|
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
|
||||||
|
|
||||||
|
{{#if this.failureMessage}}
|
||||||
|
<div class="failed bg-red-l1 ba">{{this.failureMessage}}</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<div class="modal-body bg-whitegrey-l2 ba b--whitegrey br3">
|
<div class="modal-body bg-whitegrey-l2 ba b--whitegrey br3">
|
||||||
{{#if this.response}}
|
{{#if this.response}}
|
||||||
<table class="gh-members-import-results">
|
<table class="gh-members-import-results">
|
||||||
|
@ -30,18 +34,37 @@
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</table>
|
</table>
|
||||||
{{else}}
|
{{else}}
|
||||||
<GhFileUploader
|
{{#if this.filePresent}}
|
||||||
@url={{this.uploadUrl}}
|
<GhFormGroup>
|
||||||
@paramName="membersfile"
|
<label for="label-input">Labels</label>
|
||||||
@labelText="Select or drag-and-drop a CSV file."
|
<GhMemberLabelInput @member={{this.labels}} @triggerId="label-input" />
|
||||||
@uploadStarted={{action "uploadStarted"}}
|
</GhFormGroup>
|
||||||
@uploadFinished={{action "uploadFinished"}}
|
{{else}}
|
||||||
@uploadSuccess={{action "uploadSuccess"}} />
|
<div class="upload-form">
|
||||||
|
<section class="gh-image-uploader {{this.dragClass}}">
|
||||||
|
<GhFileInput @multiple={{false}} @alt={{this.labelText}} @action={{action "fileSelected"}} @accept={{this.accept}}>
|
||||||
|
<div class="description">{{this.labelText}}</div>
|
||||||
|
</GhFileInput>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members">
|
{{#if this.response}}
|
||||||
<span>{{#if this.response}}Close{{else}}Cancel{{/if}}</span>
|
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members">
|
||||||
</button>
|
<span>Close</span>
|
||||||
|
</button>
|
||||||
|
{{else if this.failureMessage}}
|
||||||
|
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members">
|
||||||
|
<span>Cancel</span>
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members">
|
||||||
|
<span>Cancel</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="gh-btn gh-btn-green" {{action "upload"}} disabled={{this.importDisabled}}><span>Import</span></button>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,33 +1,111 @@
|
||||||
import ModalComponent from 'ghost-admin/components/modal-base';
|
import ModalComponent from 'ghost-admin/components/modal-base';
|
||||||
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
||||||
|
import {
|
||||||
|
UnsupportedMediaTypeError,
|
||||||
|
isRequestEntityTooLargeError,
|
||||||
|
isUnsupportedMediaTypeError,
|
||||||
|
isVersionMismatchError
|
||||||
|
} from 'ghost-admin/services/ajax';
|
||||||
import {computed} from '@ember/object';
|
import {computed} from '@ember/object';
|
||||||
|
import {htmlSafe} from '@ember/string';
|
||||||
|
import {isBlank} from '@ember/utils';
|
||||||
|
import {run} from '@ember/runloop';
|
||||||
|
import {inject as service} from '@ember/service';
|
||||||
|
|
||||||
export default ModalComponent.extend({
|
export default ModalComponent.extend({
|
||||||
|
ajax: service(),
|
||||||
|
notifications: service(),
|
||||||
|
|
||||||
labelText: 'Select or drag-and-drop a CSV File',
|
labelText: 'Select or drag-and-drop a CSV File',
|
||||||
|
|
||||||
|
dragClass: null,
|
||||||
|
file: null,
|
||||||
|
paramName: 'membersfile',
|
||||||
|
extensions: null,
|
||||||
|
uploading: false,
|
||||||
|
uploadPercentage: 0,
|
||||||
response: null,
|
response: null,
|
||||||
closeDisabled: false,
|
failureMessage: null,
|
||||||
|
labels: null,
|
||||||
|
|
||||||
// Allowed actions
|
// Allowed actions
|
||||||
confirm: () => {},
|
confirm: () => {},
|
||||||
|
|
||||||
|
filePresent: computed.reads('file'),
|
||||||
|
closeDisabled: computed.reads('uploading'),
|
||||||
|
|
||||||
uploadUrl: computed(function () {
|
uploadUrl: computed(function () {
|
||||||
return `${ghostPaths().apiRoot}/members/csv/`;
|
return `${ghostPaths().apiRoot}/members/csv/`;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
importDisabled: computed('file', function () {
|
||||||
|
return !this.file || !(this._validate(this.file));
|
||||||
|
}),
|
||||||
|
|
||||||
|
formData: computed('file', function () {
|
||||||
|
let paramName = this.paramName;
|
||||||
|
let file = this.file;
|
||||||
|
let formData = new FormData();
|
||||||
|
|
||||||
|
formData.append(paramName, file);
|
||||||
|
|
||||||
|
if (this.labels.labels.length) {
|
||||||
|
this.labels.labels.forEach((label) => {
|
||||||
|
formData.append('labels', label.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}),
|
||||||
|
|
||||||
|
progressStyle: computed('uploadPercentage', function () {
|
||||||
|
let percentage = this.uploadPercentage;
|
||||||
|
let width = '';
|
||||||
|
|
||||||
|
if (percentage > 0) {
|
||||||
|
width = `${percentage}%`;
|
||||||
|
} else {
|
||||||
|
width = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlSafe(`width: ${width}`);
|
||||||
|
}),
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._super(...arguments);
|
||||||
|
this.extensions = ['csv'];
|
||||||
|
|
||||||
|
// NOTE: nested label come from specific "gh-member-label-input" parameters, would be good to refactor
|
||||||
|
this.labels = {labels: []};
|
||||||
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
uploadStarted() {
|
fileSelected(fileList, resetInput) {
|
||||||
this.set('closeDisabled', true);
|
let [file] = Array.from(fileList);
|
||||||
|
let validationResult = this._validate(file);
|
||||||
|
|
||||||
|
this.set('file', file);
|
||||||
|
|
||||||
|
if (validationResult !== true) {
|
||||||
|
this._uploadFailed(validationResult);
|
||||||
|
|
||||||
|
if (resetInput) {
|
||||||
|
resetInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadFinished() {
|
reset() {
|
||||||
this.set('closeDisabled', false);
|
this.set('failureMessage', null);
|
||||||
|
this.set('labels', {labels: []});
|
||||||
|
this.set('file', null);
|
||||||
|
this.set('failureMessage', null);
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadSuccess(response) {
|
upload() {
|
||||||
this.set('response', response.meta.stats);
|
if (this.file) {
|
||||||
// invoke the passed in confirm action to refresh member data
|
this.generateRequest();
|
||||||
this.confirm();
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
confirm() {
|
confirm() {
|
||||||
|
@ -39,5 +117,119 @@ export default ModalComponent.extend({
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generateRequest() {
|
||||||
|
let ajax = this.ajax;
|
||||||
|
let formData = this.formData;
|
||||||
|
let url = this.uploadUrl;
|
||||||
|
|
||||||
|
this._uploadStarted();
|
||||||
|
ajax.post(url, {
|
||||||
|
data: formData,
|
||||||
|
processData: false,
|
||||||
|
contentType: false,
|
||||||
|
dataType: 'text',
|
||||||
|
xhr: () => {
|
||||||
|
let xhr = new window.XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
|
this._uploadProgress(event);
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
return xhr;
|
||||||
|
}
|
||||||
|
}).then((response) => {
|
||||||
|
this._uploadSuccess(JSON.parse(response));
|
||||||
|
}).catch((error) => {
|
||||||
|
this._uploadFailed(error);
|
||||||
|
}).finally(() => {
|
||||||
|
this._uploadFinished();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_uploadStarted() {
|
||||||
|
this.set('uploading', true);
|
||||||
|
},
|
||||||
|
|
||||||
|
_uploadProgress(event) {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
run(() => {
|
||||||
|
let percentage = Math.round((event.loaded / event.total) * 100);
|
||||||
|
this.set('uploadPercentage', percentage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_uploadSuccess(response) {
|
||||||
|
this.set('response', response.meta.stats);
|
||||||
|
// invoke the passed in confirm action to refresh member data
|
||||||
|
this.confirm();
|
||||||
|
},
|
||||||
|
|
||||||
|
_uploadFinished() {
|
||||||
|
this.set('uploading', false);
|
||||||
|
},
|
||||||
|
|
||||||
|
_uploadFailed(error) {
|
||||||
|
let message;
|
||||||
|
|
||||||
|
if (isVersionMismatchError(error)) {
|
||||||
|
this.notifications.showAPIError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnsupportedMediaTypeError(error)) {
|
||||||
|
message = 'The file type you uploaded is not supported.';
|
||||||
|
} else if (isRequestEntityTooLargeError(error)) {
|
||||||
|
message = 'The file you uploaded was larger than the maximum file size your server allows.';
|
||||||
|
} else if (error.payload && error.payload.errors && !isBlank(error.payload.errors[0].message)) {
|
||||||
|
message = htmlSafe(error.payload.errors[0].message);
|
||||||
|
} else {
|
||||||
|
message = 'Something went wrong :(';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set('failureMessage', message);
|
||||||
|
},
|
||||||
|
|
||||||
|
_validate(file) {
|
||||||
|
let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
|
||||||
|
let extensions = this.extensions;
|
||||||
|
|
||||||
|
if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) {
|
||||||
|
return new UnsupportedMediaTypeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,190 @@
|
||||||
|
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} 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({
|
||||||
|
showAPIError() {
|
||||||
|
// noop - to be stubbed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stubSuccessfulUpload = function (server, delay = 0) {
|
||||||
|
server.post('/ghost/api/v3/admin/members/csv/', function () {
|
||||||
|
return [200, {'Content-Type': 'application/json'}, '{"url":"/content/images/test.png"}'];
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stubFailedUpload = function (server, code, error, delay = 0) {
|
||||||
|
server.post('/ghost/api/v3/admin/members/csv/', function () {
|
||||||
|
return [code, {'Content-Type': 'application/json'}, JSON.stringify({
|
||||||
|
errors: [{
|
||||||
|
type: error,
|
||||||
|
message: `Error: ${error}`
|
||||||
|
}]
|
||||||
|
})];
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Integration: Component: modal-import-members-test', function () {
|
||||||
|
setupRenderingTest();
|
||||||
|
|
||||||
|
let server;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
server = new Pretender();
|
||||||
|
this.set('uploadUrl', '/ghost/api/v3/admin/members/csv/');
|
||||||
|
|
||||||
|
this.owner.register('service:notifications', notificationsStub);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
server.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders', async function () {
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
|
||||||
|
expect(find('h1').textContent.trim(), 'default header')
|
||||||
|
.to.equal('Import');
|
||||||
|
expect(find('.description').textContent.trim(), 'upload label')
|
||||||
|
.to.equal('Select or drag-and-drop a CSV File');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates request to supplied endpoint', async function () {
|
||||||
|
stubSuccessfulUpload(server);
|
||||||
|
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
|
||||||
|
|
||||||
|
expect(find('label').textContent.trim(), 'labels label')
|
||||||
|
.to.equal('Labels');
|
||||||
|
expect(find('.gh-btn-green').textContent).to.equal('Import');
|
||||||
|
|
||||||
|
await click('.gh-btn-green');
|
||||||
|
|
||||||
|
expect(server.handledRequests.length).to.equal(1);
|
||||||
|
expect(server.handledRequests[0].url).to.equal('/ghost/api/v3/admin/members/csv/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays server error', async function () {
|
||||||
|
stubFailedUpload(server, 415, 'UnsupportedMediaTypeError');
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays file too large for server error', async function () {
|
||||||
|
stubFailedUpload(server, 413, 'RequestEntityTooLargeError');
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
|
||||||
|
await click('.gh-btn-green');
|
||||||
|
|
||||||
|
expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
|
||||||
|
expect(find('.failed').textContent).to.match(/The file you uploaded was larger/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles file too large error directly from the web server', async function () {
|
||||||
|
server.post('/ghost/api/v3/admin/members/csv/', function () {
|
||||||
|
return [413, {}, ''];
|
||||||
|
});
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
|
||||||
|
await click('.gh-btn-green');
|
||||||
|
|
||||||
|
expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
|
||||||
|
expect(find('.failed').textContent).to.match(/The file you uploaded was larger/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays other server-side error with message', async function () {
|
||||||
|
stubFailedUpload(server, 400, 'UnknownError');
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
|
||||||
|
await click('.gh-btn-green');
|
||||||
|
|
||||||
|
expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
|
||||||
|
expect(find('.failed').textContent).to.match(/Error: UnknownError/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unknown failure', async function () {
|
||||||
|
server.post('/ghost/api/v3/admin/members/csv/', function () {
|
||||||
|
return [500, {'Content-Type': 'application/json'}, ''];
|
||||||
|
});
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
|
||||||
|
await click('.gh-btn-green');
|
||||||
|
|
||||||
|
expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
|
||||||
|
expect(find('.failed').textContent).to.match(/Something went wrong/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers notifications.showAPIError for VersionMismatchError', async function () {
|
||||||
|
let showAPIError = sinon.spy();
|
||||||
|
let notifications = this.owner.lookup('service:notifications');
|
||||||
|
notifications.set('showAPIError', showAPIError);
|
||||||
|
|
||||||
|
stubFailedUpload(server, 400, 'VersionMismatchError');
|
||||||
|
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
|
||||||
|
await click('.gh-btn-green');
|
||||||
|
|
||||||
|
expect(showAPIError.calledOnce).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doesn\'t trigger notifications.showAPIError for other errors', async function () {
|
||||||
|
let showAPIError = sinon.spy();
|
||||||
|
let notifications = this.owner.lookup('service:notifications');
|
||||||
|
notifications.set('showAPIError', showAPIError);
|
||||||
|
|
||||||
|
stubFailedUpload(server, 400, 'UnknownError');
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
|
||||||
|
await click('.gh-btn-green');
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await render(hbs`{{modal-import-members}}`);
|
||||||
|
|
||||||
|
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.txt'});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Reference in a new issue