mirror of https://github.com/docusealco/docuseal
parent
9bdc5d4863
commit
d4940234b7
@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="selectedSheetIndex === null && spreadsheet">
|
||||
<form @submit.prevent="[selectedSheetIndex = $refs.selectWorksheet.value, buildDefaultMappings()]">
|
||||
<label class="label">
|
||||
Select Worksheet
|
||||
</label>
|
||||
<select
|
||||
ref="selectWorksheet"
|
||||
class="base-select"
|
||||
>
|
||||
<option
|
||||
v-for="(sheet, index) in spreadsheet"
|
||||
:key="index"
|
||||
:value="index"
|
||||
>
|
||||
{{ sheet[0] || index }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="base-button mt-4 w-full">
|
||||
Open
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="selectedSheetIndex !== null">
|
||||
<div
|
||||
v-for="submitter in submitters"
|
||||
:key="submitter.uuid"
|
||||
class="mb-4"
|
||||
>
|
||||
<div
|
||||
v-if="submitters.length > 1"
|
||||
class="px-3 border-y py-2 border-base-300 text-center w-full"
|
||||
>
|
||||
{{ submitter.name }}
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="relative w-full py-2 px-2 text-sm">
|
||||
Recipient field
|
||||
</div>
|
||||
<div class="relative w-full py-2 pl-4 text-sm">
|
||||
Spreadsheet column
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="mapping in mappings.filter((m) => m.submitter_uuid === submitter.uuid)"
|
||||
:key="mapping.uuid"
|
||||
class="mb-2"
|
||||
>
|
||||
<div class="flex">
|
||||
<select
|
||||
class="base-select !select-sm !h-10"
|
||||
required
|
||||
@change="mapping.field_name = $event.target.value"
|
||||
>
|
||||
<option
|
||||
disabled
|
||||
value=""
|
||||
:selected="!mapping.field_name"
|
||||
>
|
||||
Select Field
|
||||
</option>
|
||||
<option
|
||||
v-for="(field, index) in selectFieldsForSubmitter(submitter)"
|
||||
:key="index"
|
||||
:value="field.name"
|
||||
:selected="mapping.field_name === field.name"
|
||||
>
|
||||
{{ field.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex items-center px-1">
|
||||
<IconArrowsHorizontal style="width: 19px; height: 19px" />
|
||||
</div>
|
||||
<div class="w-full relative">
|
||||
<select
|
||||
class="base-select !select-sm !h-10"
|
||||
required
|
||||
@change="mapping.column_index = parseInt($event.target.value)"
|
||||
>
|
||||
<option
|
||||
disabled
|
||||
value=""
|
||||
:selected="mapping.column_index == null"
|
||||
>
|
||||
Select Column
|
||||
</option>
|
||||
<template
|
||||
v-for="(column, index) in columns"
|
||||
:key="index"
|
||||
>
|
||||
<option
|
||||
v-if="column"
|
||||
:value="index"
|
||||
:selected="index === mapping.column_index"
|
||||
>
|
||||
{{ column }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<div
|
||||
v-if="mapping.column_index != null"
|
||||
class="absolute top-0 bottom-0 right-1 flex items-center"
|
||||
>
|
||||
<span
|
||||
class="tooltip tooltip-bottom-end pr-1 tooltip-pre"
|
||||
style="padding-top: 2px"
|
||||
:data-tip="[0, 1, 2].map((i) => rows[i]?.[mapping.column_index] ?? '---').join('\n')"
|
||||
>
|
||||
<button
|
||||
class="btn btn-xs btn-circle bg-white border-0 border-gray-300"
|
||||
@click.prevent
|
||||
>
|
||||
<IconInfoCircle class="h-4 w-4" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center pl-1">
|
||||
<span
|
||||
class="tooltip tooltip-top"
|
||||
data-tip="Remove"
|
||||
>
|
||||
<button
|
||||
:disabled="mappings.filter((m) => m.submitter_uuid === submitter.uuid).length < 2"
|
||||
class="btn btn-xs btn-circle"
|
||||
@click.prevent="mappings.splice(mappings.indexOf(mapping), 1)"
|
||||
>
|
||||
<IconX class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-sm btn-primary w-full !normal-case font-medium"
|
||||
@click.prevent="addMapping(submitter)"
|
||||
>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
New Field Mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
name="submissions_json"
|
||||
hidden
|
||||
:value="JSON.stringify(submissionsData.slice(0, 1100))"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 border-y py-2 border-base-300 text-center w-full text-sm font-semibold"
|
||||
>
|
||||
Total entries: {{ submissionsData.length }}
|
||||
<template v-if="submissionsData.length >= 1000">
|
||||
/ 1000
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-52 w-full"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDropFiles"
|
||||
>
|
||||
<label
|
||||
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
|
||||
for="import_list_file"
|
||||
:class="{ 'opacity-50': isLoading }"
|
||||
>
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading"
|
||||
class="animate-spin"
|
||||
:width="40"
|
||||
:height="40"
|
||||
/>
|
||||
<IconCloudUpload
|
||||
v-else
|
||||
:width="40"
|
||||
:height="40"
|
||||
/>
|
||||
<div
|
||||
class="font-medium text-lg mb-1"
|
||||
>
|
||||
Upload CSV or XLSX Spreadsheet
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">Click to Upload</span> or drag and drop files.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
ref="form"
|
||||
class="hidden"
|
||||
>
|
||||
<input
|
||||
id="import_list_file"
|
||||
ref="input"
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".xlsx, .xls, .csv"
|
||||
@change="onSelectFile"
|
||||
>
|
||||
</form>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconCloudUpload, IconX, IconPlus, IconArrowsHorizontal, IconInfoCircle, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'FileDropzone',
|
||||
components: {
|
||||
IconCloudUpload,
|
||||
IconX,
|
||||
IconArrowsHorizontal,
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconInnerShadowTop
|
||||
},
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
spreadsheet: null,
|
||||
selectedSheetIndex: null,
|
||||
mappings: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
table () {
|
||||
return this.spreadsheet[this.selectedSheetIndex][1]
|
||||
},
|
||||
submissionsData () {
|
||||
const submissions = []
|
||||
|
||||
this.rows.forEach((row) => {
|
||||
const submittersIndex = {}
|
||||
|
||||
this.mappings.forEach((mapping) => {
|
||||
if (mapping.field_name && mapping.column_index != null) {
|
||||
submittersIndex[mapping.submitter_uuid] ||= { uuid: mapping.submitter_uuid, fields: [] }
|
||||
|
||||
if (['name', 'email', 'phone'].includes(mapping.field_name.toLowerCase())) {
|
||||
submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index]
|
||||
} else {
|
||||
submittersIndex[mapping.submitter_uuid].fields.push({
|
||||
name: mapping.field_name, default_value: row[mapping.column_index], readonly: true
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(submittersIndex).length !== 0) {
|
||||
submissions.push({ submitters: Object.values(submittersIndex) })
|
||||
}
|
||||
})
|
||||
|
||||
return submissions
|
||||
},
|
||||
submitters () {
|
||||
return this.template.submitters
|
||||
},
|
||||
columns () {
|
||||
return this.table[0]
|
||||
},
|
||||
form () {
|
||||
return this.$el.closest('form')
|
||||
},
|
||||
fieldTypes () {
|
||||
return ['text', 'cells', 'date', 'number', 'radio', 'select', 'checkbox']
|
||||
},
|
||||
defaultFields () {
|
||||
return [
|
||||
{ name: 'Name' },
|
||||
{ name: 'Email' },
|
||||
{ name: 'Phone' }
|
||||
]
|
||||
},
|
||||
rows () {
|
||||
return this.table.slice(1)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedSheetIndex (value) {
|
||||
if (value !== null) {
|
||||
document.getElementById('list_form_buttons')?.classList?.remove('hidden')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDropFiles (e) {
|
||||
this.uploadFile(e.dataTransfer.files[0])
|
||||
},
|
||||
onSelectFile (e) {
|
||||
this.uploadFile(e.target.files[0])
|
||||
},
|
||||
addMapping (submitter) {
|
||||
this.mappings.push({ uuid: v4(), field_name: '', column_index: null, submitter_uuid: submitter.uuid })
|
||||
},
|
||||
selectFieldsForSubmitter (submitter) {
|
||||
const templateFields = this.template.fields.filter((field) => {
|
||||
return field.submitter_uuid === submitter.uuid &&
|
||||
field.name &&
|
||||
this.fieldTypes.includes(field.type) &&
|
||||
this.defaultFields.every((f) => field.name?.toLowerCase() !== f.name?.toLowerCase())
|
||||
})
|
||||
|
||||
return [...this.defaultFields, ...templateFields]
|
||||
},
|
||||
buildDefaultMappings () {
|
||||
this.submitters.forEach((submitter) => {
|
||||
const fields = this.selectFieldsForSubmitter(submitter)
|
||||
|
||||
fields.forEach((field) => {
|
||||
const columnIndex = this.columns.findIndex((column, index) => {
|
||||
return column &&
|
||||
column.toString().toLowerCase().includes(field.name?.toLowerCase()) &&
|
||||
this.mappings.every((m) => m.column_index !== index)
|
||||
})
|
||||
|
||||
if (columnIndex !== -1) {
|
||||
this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid })
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'name' && m.submitter_uuid === submitter.uuid)) {
|
||||
this.mappings.unshift({ uuid: v4(), field_name: 'Name', submitter_uuid: submitter.uuid })
|
||||
}
|
||||
|
||||
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'email' && m.submitter_uuid === submitter.uuid)) {
|
||||
this.mappings.unshift({ uuid: v4(), field_name: 'Email', submitter_uuid: submitter.uuid })
|
||||
}
|
||||
})
|
||||
},
|
||||
uploadFile (file) {
|
||||
this.isLoading = true
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('file', file)
|
||||
|
||||
return fetch('/upload_spreadsheet', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-Token': this.authenticityToken
|
||||
}
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
if (data.error) {
|
||||
return alert(data.error)
|
||||
}
|
||||
|
||||
this.spreadsheet = data
|
||||
|
||||
if (data.length === 1) {
|
||||
this.selectedSheetIndex = 0
|
||||
|
||||
this.buildDefaultMappings()
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,11 @@
|
||||
<div class="alert">
|
||||
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
|
||||
<div>
|
||||
<p class="font-bold">Bulk send from Excel XLSX or CSV</p>
|
||||
<p class="text-gray-700">
|
||||
Unlock with DocuSeal Pro
|
||||
<br>
|
||||
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1 @@
|
||||
<%= render 'submissions/bulk_send_placeholder' %>
|
||||
Loading…
Reference in new issue