Skip to content

Commit 647b8b3

Browse files
committed
Import enhancement 🚀
- Add import template - Use restore helper for bulk import - Extract sheet & users generator as helper - Uppercased role keys
1 parent e699c2f commit 647b8b3

File tree

16 files changed

+176
-169
lines changed

16 files changed

+176
-169
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ DATABASE_PASSWORD=
4646
DATABASE_HOST=
4747
DATABASE_PORT=
4848
DATABASE_ADAPTER=postgres
49+
50+
# Import format (csv or xlsx are supported)
51+
IMPORT_TEMPLATE_FORMAT=

client/admin/api/user.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { extractData, processParams } from '@/common/api/helpers';
2+
import get from 'lodash/get';
23
import request from '@/common/api/request';
34
import urljoin from 'url-join';
45

56
const urls = {
67
base: '/users',
78
resource: ({ id }) => urljoin(urls.base, `${id}`),
89
invite: ({ id }) => urljoin(urls.base, `${id}`, 'invite'),
9-
import: () => urljoin(urls.base, 'import')
10+
import: () => urljoin(urls.base, 'import'),
11+
getImportTemplate: () => urljoin(urls.import(), 'template')
1012
};
1113

1214
function fetch(params = {}) {
@@ -30,8 +32,14 @@ function invite(item) {
3032
return request.post(urls.invite(item));
3133
}
3234

33-
function bulkImport(items) {
34-
return request.post(urls.import(), items, { responseType: 'blob' });
35+
async function bulkImport(items) {
36+
const options = { responseType: 'blob' };
37+
const { data, headers } = await request.post(urls.import(), items, options);
38+
return { data, count: parseInt(get(headers, 'data-imported-count'), 10) };
39+
}
40+
41+
function getImportTemplate() {
42+
return request.get(urls.getImportTemplate(), { responseType: 'blob' });
3543
}
3644

3745
export default {
@@ -40,5 +48,6 @@ export default {
4048
update,
4149
remove,
4250
invite,
43-
bulkImport
51+
bulkImport,
52+
getImportTemplate
4453
};
Lines changed: 73 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,62 @@
11
<template>
2-
<v-dialog
3-
v-model="showDialog"
4-
v-hotkey="{ esc: close }"
5-
persistent
6-
no-click-animation
7-
width="700">
8-
<template #activator="{ on, attrs }">
9-
<v-btn
10-
v-on="on"
11-
v-bind="attrs"
12-
color="blue-grey"
13-
outlined
14-
class="mr-4">
15-
<v-icon>mdi-cloud-upload</v-icon>Import
2+
<admin-dialog
3+
v-model="visible"
4+
@click:outside="close"
5+
width="600"
6+
header-icon="mdi-cloud-upload">
7+
<template #activator="{ on }">
8+
<v-btn v-on="on" color="primary" text>
9+
<v-icon dense class="mr-1">mdi-cloud-upload</v-icon>Import users
1610
</v-btn>
1711
</template>
18-
<form @submit.prevent="save">
19-
<v-card class="pa-3">
20-
<v-card-title class="headline">Import Users</v-card-title>
21-
<v-card-text>
22-
<validation-provider
23-
ref="fileProvider"
24-
v-slot="{ errors }"
25-
name="fileInput"
26-
:rules="{ required: true, mimes }">
27-
<label for="userImportInput">
28-
<v-text-field
29-
ref="fileName"
30-
v-model="filename"
31-
:error-messages="errors"
32-
:disabled="importing"
33-
prepend-icon="mdi-attachment"
34-
label="Upload .xlsx or .csv file"
35-
readonly
36-
single-line />
37-
<input
38-
ref="fileInput"
39-
@change="onFileSelected"
40-
id="userImportInput"
41-
name="file"
42-
type="file"
43-
class="file-input">
44-
</label>
45-
</validation-provider>
46-
<v-alert
47-
v-if="error"
48-
transition="fade-transition"
49-
dismissible text dense
50-
class="mb-7 text-left">
51-
{{ error }}
52-
</v-alert>
53-
</v-card-text>
54-
<v-card-actions>
12+
<template #header>Import Users</template>
13+
<template #body>
14+
<validation-observer
15+
v-if="visible"
16+
ref="form"
17+
v-slot="{ invalid }"
18+
@submit.prevent="$refs.form.handleSubmit(submit)"
19+
tag="form"
20+
novalidate>
21+
<validation-provider
22+
v-slot="{ errors }"
23+
:rules="inputValidation"
24+
name="file"
25+
slim>
26+
<v-file-input
27+
v-model="file"
28+
:accept="acceptedFiles"
29+
:error-messages="errors"
30+
:disabled="importing"
31+
prepend-icon="mdi-attachment"
32+
label="Upload .xlsx or .csv file" />
33+
</validation-provider>
34+
<div class="d-flex my-2">
35+
<v-btn @click="downloadTemplateFile" color="primary" text>
36+
Download Template
37+
</v-btn>
5538
<v-spacer />
5639
<v-fade-transition>
5740
<v-btn
5841
v-show="serverErrorsReport"
5942
@click="downloadErrorsFile"
60-
color="error">
61-
<v-icon>mdi-cloud-download</v-icon>Errors
43+
color="error"
44+
text>
45+
<v-icon class="mr-1">mdi-cloud-download</v-icon>Errors
6246
</v-btn>
6347
</v-fade-transition>
64-
<v-btn @click="close">Cancel</v-btn>
65-
<v-btn :disabled="importDisabled" color="success" type="submit">
66-
<span v-if="!importing">Import</span>
67-
<v-icon v-else>mdi-loading mdi-spin</v-icon>
48+
<v-btn @click="close" text>Cancel</v-btn>
49+
<v-btn :disabled="invalid" :loading="importing" type="submit" text>
50+
Import
6851
</v-btn>
69-
</v-card-actions>
70-
</v-card>
71-
</form>
72-
</v-dialog>
52+
</div>
53+
</validation-observer>
54+
</template>
55+
</admin-dialog>
7356
</template>
7457

7558
<script>
59+
import AdminDialog from '@/admin/components/common/Dialog';
7660
import api from '@/admin/api/user';
7761
import saveAs from 'save-as';
7862
@@ -84,92 +68,71 @@ const inputFormats = {
8468
export default {
8569
name: 'import-dialog',
8670
data: () => ({
87-
showDialog: false,
71+
visible: false,
8872
importing: false,
89-
filename: null,
73+
file: null,
9074
form: null,
91-
error: null,
9275
serverErrorsReport: null
9376
}),
9477
computed: {
95-
importDisabled: vm => !vm.filename || vm.importing,
96-
mimes: () => Object.keys(inputFormats)
78+
inputValidation: () => ({ required: true, mimes: Object.keys(inputFormats) }),
79+
acceptedFiles: () => Object.keys(inputFormats)
9780
},
9881
methods: {
99-
async onFileSelected(e) {
100-
const { valid } = await this.$refs.fileProvider.validate(e);
101-
if (!valid) return;
102-
this.form = new FormData();
103-
this.resetErrors();
104-
const [file] = e.target.files;
105-
this.filename = file.name;
106-
this.form.append('file', file, file.name);
107-
},
10882
close() {
10983
if (this.importing) return;
110-
if (this.$refs.fileInput) this.$refs.fileInput.value = null;
111-
this.filename = null;
112-
this.resetErrors();
113-
this.showDialog = false;
84+
this.file = null;
85+
this.serverErrorsReport = null;
86+
this.visible = false;
11487
},
115-
save() {
88+
submit() {
11689
this.importing = true;
117-
return api.bulkImport(this.form).then(response => {
90+
const { file } = this;
91+
this.form = new FormData();
92+
this.form.append('file', file, file.name);
93+
return api.bulkImport(this.form).then(({ data, count }) => {
11894
this.importing = false;
119-
if (response.data.size) {
120-
this.$nextTick(() => this.$refs.fileName.focus());
121-
this.error = 'All users aren\'t imported';
122-
this.serverErrorsReport = response.data;
123-
return;
124-
}
125-
this.$emit('imported');
126-
this.close();
95+
if (count) this.$emit('imported');
96+
if (!data.size) return this.close();
97+
const message = `${count} users were successfully imported.`;
98+
this.$refs.form.setErrors({ file: [message] });
99+
this.serverErrorsReport = data;
127100
}).catch(err => {
128101
this.importing = false;
129-
this.error = 'Importing users failed.';
130-
this.$nextTick(() => this.$refs.fileName.focus());
102+
const message = 'Importing users failed.';
103+
this.$refs.form.setErrors({ file: [message] });
131104
return Promise.reject(err);
132105
});
133106
},
134107
downloadErrorsFile() {
135108
const extension = inputFormats[this.serverErrorsReport.type];
136109
saveAs(this.serverErrorsReport, `Errors.${extension}`);
137-
this.$refs.fileName.focus();
138110
},
139-
resetErrors() {
140-
this.serverErrorsReport = null;
141-
this.error = null;
142-
this.$refs.form?.reset();
111+
async downloadTemplateFile() {
112+
const { data } = await api.getImportTemplate();
113+
saveAs(data, `Template.${inputFormats[data.type]}`);
143114
}
144-
}
115+
},
116+
components: { AdminDialog }
145117
};
146118
</script>
147119

148120
<style lang="scss" scoped>
149-
.file-input {
121+
.v-form input {
150122
display: none;
151123
}
152124
153-
.v-btn .v-icon {
154-
padding-right: 0.375rem;
155-
}
156-
157-
.v-text-field {
158-
::v-deep .v-text-field__slot {
125+
.v-text-field ::v-deep {
126+
.v-text-field__slot {
159127
cursor: pointer;
160128
161129
input {
162130
pointer-events: none;
163131
}
164132
}
165133
166-
::v-deep .mdi {
134+
.mdi {
167135
transform: rotate(-90deg);
168136
}
169137
}
170-
171-
.loader-container {
172-
display: flex;
173-
justify-content: center;
174-
}
175138
</style>

client/admin/router.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const router = new Router({
2424
router.beforeEach((to, _from, next) => {
2525
const user = get(store.state, 'auth.user');
2626
const isNotAuthenticated = to.matched.some(it => it.meta.auth) && !user;
27-
const isNotAuthorized = user && user.role !== Role.Admin;
27+
const isNotAuthorized = user && user.role !== Role.ADMIN;
2828
if (isNotAuthenticated || isNotAuthorized) return navigate();
2929
next();
3030
});

client/main/components/auth/Login.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export default {
7777
this.message = '';
7878
this.login({ email: this.email, password: this.password })
7979
.then(user => {
80-
if (user.role !== Role.Admin) return this.$router.push('/');
80+
if (user.role !== Role.ADMIN) return this.$router.push('/');
8181
document.location.replace(`${document.location.origin}/admin`);
8282
})
8383
.catch(() => (this.errorMessage = LOGIN_ERR_MESSAGE));

client/main/router.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ router.beforeEach((to, _from, next) => {
4747
const user = get(store.state, 'auth.user');
4848
const isNotAuthenticated = to.matched.some(it => it.meta.auth) && !user;
4949
if (isNotAuthenticated) return next({ name: 'login' });
50-
if (user && user.role === Role.Admin) return navigate('/admin/');
50+
if (user && user.role === Role.ADMIN) return navigate('/admin/');
5151
return next();
5252
});
5353

common/config/role.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict';
22

33
module.exports = {
4-
Admin: 'ADMIN',
5-
User: 'USER'
4+
ADMIN: 'ADMIN',
5+
USER: 'USER'
66
};

server/common/auth/mw.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const HttpStatus = require('http-status');
55
const { Role } = require('../../../common/config');
66

77
function authorize(...allowed) {
8-
allowed.push(Role.Admin);
8+
allowed.push(Role.ADMIN);
99
return ({ user }, res, next) => {
1010
if (!user) return createError(HttpStatus.UNAUTHORIZED, 'Access restricted');
1111
if (!allowed.includes(user.role)) {

server/common/database/restore.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict';
22

3-
const capitalize = require('change-case').upperCaseFirst;
3+
const { capitalCase } = require('change-case');
44
const find = require('lodash/find');
55
const Promise = require('bluebird');
66
const transform = require('lodash/transform');
@@ -27,7 +27,7 @@ async function restoreOrBuildAll(Model, items = [], where = {}, options = {}) {
2727
const results = await Promise.map(items, item => pTuple(() => {
2828
const model = find(found, processSearchKey(modelSearchKey, item));
2929
if (model && !model.deletedAt) {
30-
const message = `${capitalize(name(Model))} already exists`;
30+
const message = `${capitalCase(name(Model))} already exists`;
3131
throw new UniqueConstraintError({ message });
3232
}
3333
if (!model) return save ? Model.create(item) : Model.build(item);

server/common/database/seeds/users.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ const users = [{
1212
last_name: 'Example',
1313
1414
password: hash('admin123'),
15-
role: Role.Admin,
15+
role: Role.ADMIN,
1616
created_at: now,
1717
updated_at: now
1818
}, {
1919
first_name: 'User',
2020
last_name: 'Example',
2121
2222
password: hash('user123'),
23-
role: Role.User,
23+
role: Role.USER,
2424
created_at: now,
2525
updated_at: now
2626
}];
File renamed without changes.

0 commit comments

Comments
 (0)