|
| 1 | +<template> |
| 2 | + <div> |
| 3 | + <div class="control margin-bottom-3"> |
| 4 | + <template v-if="values.filename === ''"> |
| 5 | + <label class="control__label control__label--small">{{ |
| 6 | + $t('tableExcelImporter.chooseFileLabel') |
| 7 | + }}</label> |
| 8 | + <div class="control__description"> |
| 9 | + {{ $t('tableExcelImporter.chooseFileDescription') }} |
| 10 | + </div> |
| 11 | + </template> |
| 12 | + <div class="control__elements"> |
| 13 | + <div class="file-upload"> |
| 14 | + <input |
| 15 | + v-show="false" |
| 16 | + ref="file" |
| 17 | + type="file" |
| 18 | + accept=".xlsx,.xls,.ods" |
| 19 | + @change="select($event)" |
| 20 | + /> |
| 21 | + <Button |
| 22 | + tag="a" |
| 23 | + type="upload" |
| 24 | + size="large" |
| 25 | + :loading="state !== null" |
| 26 | + :disabled="disabled" |
| 27 | + icon="iconoir-cloud-upload" |
| 28 | + class="file-upload__button" |
| 29 | + @click.prevent="!disabled && $refs.file.click($event)" |
| 30 | + > |
| 31 | + {{ $t('tableExcelImporter.chooseFile') }} |
| 32 | + </Button> |
| 33 | + <div v-if="state === null" class="file-upload__file"> |
| 34 | + {{ values.filename }} |
| 35 | + </div> |
| 36 | + <template v-else> |
| 37 | + <ProgressBar |
| 38 | + :value="fileLoadingProgress" |
| 39 | + :show-value="state === 'loading'" |
| 40 | + :status=" |
| 41 | + state === 'loading' ? $t('importer.loading') : stateTitle |
| 42 | + " |
| 43 | + /> |
| 44 | + </template> |
| 45 | + </div> |
| 46 | + <div v-if="v$.values.filename.$error" class="error"> |
| 47 | + {{ v$.values.filename.$errors[0]?.$message }} |
| 48 | + </div> |
| 49 | + </div> |
| 50 | + </div> |
| 51 | + <div v-if="values.filename" class="row"> |
| 52 | + <div class="col col-8"> |
| 53 | + <div class="control"> |
| 54 | + <label class="control__label control__label--small">{{ |
| 55 | + $t('tableExcelImporter.sheet') |
| 56 | + }}</label> |
| 57 | + <div class="control__elements"> |
| 58 | + <Dropdown |
| 59 | + v-model="selectedSheet" |
| 60 | + :disabled="isDisabled || sheetNames.length === 0" |
| 61 | + @input="reload()" |
| 62 | + > |
| 63 | + <DropdownItem |
| 64 | + v-for="name in sheetNames" |
| 65 | + :key="name" |
| 66 | + :name="name" |
| 67 | + :value="name" |
| 68 | + /> |
| 69 | + </Dropdown> |
| 70 | + </div> |
| 71 | + </div> |
| 72 | + </div> |
| 73 | + <div class="col col-4"> |
| 74 | + <div class="control"> |
| 75 | + <label class="control__label control__label--small">{{ |
| 76 | + $t('tableExcelImporter.firstRowHeader') |
| 77 | + }}</label> |
| 78 | + <div class="control__elements"> |
| 79 | + <Checkbox |
| 80 | + v-model="firstRowHeader" |
| 81 | + :disabled="isDisabled" |
| 82 | + @input="reloadPreview()" |
| 83 | + >{{ $t('common.yes') }}</Checkbox |
| 84 | + > |
| 85 | + </div> |
| 86 | + </div> |
| 87 | + </div> |
| 88 | + </div> |
| 89 | + <div v-if="values.filename && error === ''" class="row"> |
| 90 | + <div class="col col-8 margin-top-1"><slot name="upsertMapping" /></div> |
| 91 | + </div> |
| 92 | + <Alert v-if="error !== ''" type="error"> |
| 93 | + <template #title> {{ $t('common.wrong') }} </template> |
| 94 | + {{ error }} |
| 95 | + </Alert> |
| 96 | + </div> |
| 97 | +</template> |
| 98 | + |
| 99 | +<script> |
| 100 | +import { required, helpers } from '@vuelidate/validators' |
| 101 | +import { useVuelidate } from '@vuelidate/core' |
| 102 | +import { useRuntimeConfig } from '#imports' |
| 103 | +
|
| 104 | +import form from '@baserow/modules/core/mixins/form' |
| 105 | +import importer from '@baserow/modules/database/mixins/importer' |
| 106 | +import { ExcelParser } from '@baserow/modules/database/utils/excel' |
| 107 | +
|
| 108 | +// Number of rows fetched for the preview parse. Kept small so the initial |
| 109 | +// parse is fast even on large workbooks, but generous enough to absorb a |
| 110 | +// header row plus the importer's preview window with room for sparse files |
| 111 | +// (leading blank rows etc.). |
| 112 | +const PREVIEW_ROW_LIMIT = 50 |
| 113 | +
|
| 114 | +export default { |
| 115 | + name: 'TableExcelImporter', |
| 116 | + mixins: [form, importer], |
| 117 | + emits: ['changed', 'data', 'getData'], |
| 118 | + setup() { |
| 119 | + const config = useRuntimeConfig() |
| 120 | + return { v$: useVuelidate({ $lazy: true }), config } |
| 121 | + }, |
| 122 | + data() { |
| 123 | + return { |
| 124 | + firstRowHeader: true, |
| 125 | + rawData: null, |
| 126 | + parser: null, |
| 127 | + sheetNames: [], |
| 128 | + selectedSheet: '', |
| 129 | + parsedData: null, |
| 130 | + values: { |
| 131 | + filename: '', |
| 132 | + }, |
| 133 | + } |
| 134 | + }, |
| 135 | + validations() { |
| 136 | + return { |
| 137 | + values: { |
| 138 | + filename: { |
| 139 | + required: helpers.withMessage( |
| 140 | + this.$t('error.requiredField'), |
| 141 | + required |
| 142 | + ), |
| 143 | + }, |
| 144 | + }, |
| 145 | + } |
| 146 | + }, |
| 147 | + computed: { |
| 148 | + isDisabled() { |
| 149 | + return this.disabled || this.state !== null |
| 150 | + }, |
| 151 | + }, |
| 152 | + methods: { |
| 153 | + /** |
| 154 | + * Method that is called when a file has been chosen. It will check if the file |
| 155 | + * is not larger than the configured limit. Otherwise the file is read into |
| 156 | + * memory and reload is called which parses the workbook and prepares the |
| 157 | + * preview. |
| 158 | + */ |
| 159 | + select(event) { |
| 160 | + if (event.target.files.length === 0) { |
| 161 | + return |
| 162 | + } |
| 163 | +
|
| 164 | + const file = event.target.files[0] |
| 165 | + const maxSize = |
| 166 | + parseInt(this.config.public.baserowMaxImportFileSizeMb, 10) * |
| 167 | + 1024 * |
| 168 | + 1024 |
| 169 | +
|
| 170 | + if (file.size > maxSize) { |
| 171 | + this.values.filename = '' |
| 172 | + this.handleImporterError( |
| 173 | + this.$t('tableExcelImporter.limitFileSize', { |
| 174 | + limit: this.config.public.baserowMaxImportFileSizeMb, |
| 175 | + }) |
| 176 | + ) |
| 177 | + return |
| 178 | + } |
| 179 | +
|
| 180 | + this.resetImporterState() |
| 181 | + this.fileLoadingProgress = 0 |
| 182 | + this.parser = null |
| 183 | + this.sheetNames = [] |
| 184 | + this.selectedSheet = '' |
| 185 | + this.parsedData = null |
| 186 | +
|
| 187 | + this.$emit('changed') |
| 188 | + this.values.filename = file.name |
| 189 | + this.state = 'loading' |
| 190 | + const reader = new FileReader() |
| 191 | + reader.addEventListener('progress', (event) => { |
| 192 | + this.fileLoadingProgress = (event.loaded / event.total) * 100 |
| 193 | + }) |
| 194 | + reader.addEventListener('load', (event) => { |
| 195 | + this.rawData = event.target.result |
| 196 | + this.fileLoadingProgress = 100 |
| 197 | + this.reload() |
| 198 | + }) |
| 199 | + reader.readAsArrayBuffer(event.target.files[0]) |
| 200 | + }, |
| 201 | + /** |
| 202 | + * Parses the raw workbook data with SheetJS and prepares the preview for |
| 203 | + * the currently selected sheet. To keep the initial parse fast even on |
| 204 | + * large workbooks we only read the first `PREVIEW_ROW_LIMIT` rows here; |
| 205 | + * the full file is re-parsed on demand from `getData()` when the user |
| 206 | + * actually submits the import. Cell values are read as formatted text so |
| 207 | + * that dates, numbers and booleans are imported the way the user sees |
| 208 | + * them in their spreadsheet. |
| 209 | + */ |
| 210 | + async reload() { |
| 211 | + const fileName = this.values.filename |
| 212 | + const previousSheet = this.selectedSheet |
| 213 | + this.resetImporterState() |
| 214 | + this.values.filename = fileName |
| 215 | +
|
| 216 | + this.state = 'parsing' |
| 217 | + await this.$ensureRender() |
| 218 | +
|
| 219 | + try { |
| 220 | + if (this.parser === null) { |
| 221 | + const parser = new ExcelParser() |
| 222 | + this.sheetNames = await parser.parse(this.rawData, { |
| 223 | + previewRows: PREVIEW_ROW_LIMIT, |
| 224 | + }) |
| 225 | + if (this.sheetNames.length === 0) { |
| 226 | + this.handleImporterError(this.$t('tableExcelImporter.emptyError')) |
| 227 | + return |
| 228 | + } |
| 229 | + this.parser = parser |
| 230 | + this.selectedSheet = |
| 231 | + previousSheet && this.sheetNames.includes(previousSheet) |
| 232 | + ? previousSheet |
| 233 | + : this.sheetNames[0] |
| 234 | + } |
| 235 | + } catch (error) { |
| 236 | + this.handleImporterError( |
| 237 | + this.$t('tableExcelImporter.processingError', { |
| 238 | + error: error.message, |
| 239 | + }) |
| 240 | + ) |
| 241 | + return |
| 242 | + } |
| 243 | +
|
| 244 | + await this.$ensureRender() |
| 245 | +
|
| 246 | + let rows |
| 247 | + let totalRowCount |
| 248 | + try { |
| 249 | + rows = this.parser.getSheetRows(this.selectedSheet) |
| 250 | + totalRowCount = this.parser.getTotalRowCount(this.selectedSheet) |
| 251 | + } catch (error) { |
| 252 | + this.handleSheetError( |
| 253 | + this.$t('tableExcelImporter.processingError', { |
| 254 | + error: error.message, |
| 255 | + }) |
| 256 | + ) |
| 257 | + return |
| 258 | + } |
| 259 | +
|
| 260 | + if (rows.length === 0) { |
| 261 | + this.handleSheetError(this.$t('tableExcelImporter.emptySheetError')) |
| 262 | + return |
| 263 | + } |
| 264 | +
|
| 265 | + // Limit check uses the sheet's full range (preserved on `!fullref` |
| 266 | + // through the partial parse) so we can reject oversized files before |
| 267 | + // the user fills out the rest of the form. |
| 268 | + const limit = parseInt(this.config.public.initialTableDataLimit, 10) |
| 269 | + if (limit && totalRowCount > limit) { |
| 270 | + this.handleSheetError( |
| 271 | + this.$t('tableExcelImporter.limitError', { limit }) |
| 272 | + ) |
| 273 | + return |
| 274 | + } |
| 275 | +
|
| 276 | + this.parsedData = rows |
| 277 | + this.reloadPreview() |
| 278 | + this.state = null |
| 279 | +
|
| 280 | + const getData = async () => { |
| 281 | + // Re-parse the whole file now that the user is committing to import. |
| 282 | + // The progress bar in the parent submit flow covers the wait. |
| 283 | + const fullParser = new ExcelParser() |
| 284 | + await fullParser.parse(this.rawData) |
| 285 | + const allRows = fullParser.getSheetRows(this.selectedSheet) |
| 286 | + if (this.firstRowHeader) { |
| 287 | + const [, ...data] = allRows |
| 288 | + return data |
| 289 | + } |
| 290 | + return allRows |
| 291 | + } |
| 292 | + this.$emit('getData', getData) |
| 293 | + }, |
| 294 | + /** |
| 295 | + * Handle an error that's specific to the currently selected sheet (empty |
| 296 | + * sheet, oversized sheet, sheet decoding failure). Unlike |
| 297 | + * `handleImporterError` from the importer mixin, this preserves |
| 298 | + * `values.filename` and the cached workbook/parser state so the user can |
| 299 | + * recover by switching to a different sheet via the sheet picker, rather |
| 300 | + * than being forced to re-upload the file. |
| 301 | + */ |
| 302 | + handleSheetError(message) { |
| 303 | + this.state = null |
| 304 | + this.fileLoadingProgress = 0 |
| 305 | + this.parsedData = null |
| 306 | + this.error = message |
| 307 | + this.$emit('getData', null) |
| 308 | + this.$emit('data', { header: [], previewData: [] }) |
| 309 | + }, |
| 310 | + /** |
| 311 | + * Reload the preview without re-parsing the workbook. |
| 312 | + */ |
| 313 | + reloadPreview() { |
| 314 | + if (!Array.isArray(this.parsedData) || this.parsedData.length === 0) { |
| 315 | + return |
| 316 | + } |
| 317 | +
|
| 318 | + const [rawHeader, ...rawData] = this.firstRowHeader |
| 319 | + ? this.parsedData |
| 320 | + : [[], ...this.parsedData] |
| 321 | +
|
| 322 | + const header = this.prepareHeader(rawHeader, rawData) |
| 323 | + const previewData = this.getPreview(header, rawData) |
| 324 | + this.$emit('data', { header, previewData }) |
| 325 | + }, |
| 326 | + }, |
| 327 | +} |
| 328 | +</script> |
0 commit comments