Skip to content

Commit 3b56679

Browse files
authored
feat: Add Excel import (baserow#5265)
1 parent 3b9730d commit 3b56679

13 files changed

Lines changed: 708 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "feature",
3+
"message": "Add support for importing Excel files into database tables",
4+
"domain": "database",
5+
"bullet_points": [],
6+
"created_at": "2026-04-26"
7+
}

web-frontend/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,8 @@
476476
"csv": "Import a CSV file",
477477
"paste": "Paste table data",
478478
"xml": "Import an XML file",
479-
"json": "Import a JSON file"
479+
"json": "Import a JSON file",
480+
"excel": "Import an Excel file"
480481
},
481482
"apiDocs": {
482483
"intro": "Introduction",

web-frontend/modules/database/components/onboarding/DatabaseImportStep.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
v-if="dataLoaded"
5656
:rows="previewFileData"
5757
:fields="fileFields"
58+
:field-options="fileFieldOptions"
5859
:border="true"
5960
/>
6061
</div>
@@ -103,6 +104,11 @@ export default {
103104
order: index,
104105
}))
105106
},
107+
fileFieldOptions() {
108+
return Object.fromEntries(
109+
this.fileFields.map((field) => [field.id, { hidden: false }])
110+
)
111+
},
106112
previewFileData() {
107113
return this.previewData.map((row) => {
108114
const newRow = Object.fromEntries(

web-frontend/modules/database/components/table/CreateTable.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
class="import-modal__preview margin-bottom-2"
2424
:rows="previewFileData"
2525
:fields="fileFields"
26+
:field-options="fileFieldOptions"
2627
/>
2728

2829
<div v-if="!hasErrors" class="modal-progress__actions">
@@ -118,6 +119,11 @@ export default {
118119
order: index,
119120
}))
120121
},
122+
fileFieldOptions() {
123+
return Object.fromEntries(
124+
this.fileFields.map((field) => [field.id, { hidden: false }])
125+
)
126+
},
121127
previewFileData() {
122128
return this.previewData.map((row) => {
123129
const newRow = Object.fromEntries(

web-frontend/modules/database/components/table/CreateTableModal.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export default {
120120
return
121121
}
122122
this.chosenType = type
123+
this.$refs.createComponent?.reset()
123124
},
124125
},
125126
}
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
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

Comments
 (0)