1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-03-15 13:04:50 +00:00
bramw_baserow/web-frontend/modules/database/components/table/ImportFileModal.vue

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

667 lines
20 KiB
Vue
Raw Normal View History

2022-07-25 14:28:47 +00:00
<template>
<Modal
:right-sidebar="!isTableCreation"
:right-sidebar-scrollable="true"
:close-button="false"
:content-scrollable="true"
@show=";[(importer = ''), reset()]"
2022-07-25 14:28:47 +00:00
@hide="stopPollIfRunning()"
>
<template #content>
<div class="import-modal__header">
<h2 class="import-modal__title">
{{
isTableCreation
? $t('importFileModal.title')
: $t('importFileModal.additionalImportTitle', {
table: table.name,
})
}}
</h2>
2024-01-11 09:36:06 +00:00
<div class="modal__actions">
<a v-if="isTableCreation" class="modal__close" @click="hide()">
<i class="iconoir-cancel"></i>
</a>
</div>
</div>
<div class="control">
<label class="control__label">
{{ $t('importFileModal.importLabel') }}
</label>
<div class="control__elements">
<ul class="choice-items">
<li v-if="isTableCreation">
<a
class="choice-items__link"
:class="{ active: importer === '' }"
@click=";[(importer = ''), reset()]"
>
2023-09-28 13:39:41 +00:00
<i class="choice-items__icon iconoir-copy"></i>
<span>{{ $t('importFileModal.newTable') }}</span>
<i
v-if="importer === ''"
class="choice-items__icon-active iconoir-check-circle"
></i>
</a>
</li>
<li v-for="importerType in importerTypes" :key="importerType.type">
<a
class="choice-items__link"
:class="{ active: importer === importerType.type }"
@click=";[(importer = importerType.type), reset()]"
>
<i
2023-09-28 13:39:41 +00:00
class="choice-items__icon"
:class="importerType.iconClass"
></i>
<span> {{ importerType.getName() }}</span>
<i
v-if="importer === importerType.type"
class="choice-items__icon-active iconoir-check-circle"
></i>
</a>
</li>
</ul>
</div>
</div>
2022-07-25 14:28:47 +00:00
<TableForm
ref="tableForm"
:default-name="getDefaultName()"
2022-07-25 14:28:47 +00:00
:creation="isTableCreation"
@submitted="submitted"
>
<component
:is="importerComponent"
:disabled="importInProgress"
@changed="reset()"
@header="onHeader($event)"
2022-09-15 08:59:42 +00:00
@data="onData($event)"
@getData="onGetData($event)"
/>
</TableForm>
<Error :error="error"></Error>
2023-12-01 07:50:54 +00:00
<Alert v-if="errorReport.length > 0 && error.visible" type="warning">
<template #title>{{
$t('importFileModal.reportTitleFailure')
}}</template>
{{ $t('importFileModal.reportMessage') }} {{ errorReport.join(', ') }}
</Alert>
2023-12-01 07:50:54 +00:00
<Alert v-if="errorReport.length > 0 && !error.visible" type="warning">
<template #title>
{{ $t('importFileModal.reportTitleSuccess') }}</template
>
{{ $t('importFileModal.reportMessage') }}
{{ errorReport.join(', ') }}
</Alert>
2024-05-06 08:07:25 +00:00
<Tabs v-if="dataLoaded" no-padding>
<Tab
v-if="!isTableCreation"
2022-09-15 08:59:42 +00:00
:title="$t('importFileModal.importPreview')"
2022-07-25 14:28:47 +00:00
>
<SimpleGrid
class="import-modal__preview"
:rows="previewImportData"
:fields="fields"
2022-07-25 14:28:47 +00:00
/>
</Tab>
<Tab :title="$t('importFileModal.filePreview')">
<SimpleGrid
class="import-modal__preview"
:rows="previewFileData"
:fields="fileFields"
/>
</Tab>
</Tabs>
<div
v-if="!jobHasSucceeded || errorReport.length === 0"
class="modal-progress__actions"
>
<ProgressBar
v-if="importInProgress && showProgressBar"
:value="progressPercentage"
:status="humanReadableState"
/>
<div class="align-right">
2024-04-15 11:29:17 +00:00
<Button
type="primary"
size="large"
:loading="importInProgress || (jobHasSucceeded && !isTableCreated)"
:disabled="
importInProgress ||
!canBeSubmitted ||
(jobHasSucceeded && !isTableCreated)
"
@click="$refs.tableForm.submit()"
2022-07-25 14:28:47 +00:00
>
{{
isTableCreation
? $t('importFileModal.addButton')
: $t('importFileModal.importButton')
2022-07-25 14:28:47 +00:00
}}
2024-04-15 11:29:17 +00:00
</Button>
2022-07-25 14:28:47 +00:00
</div>
</div>
<div v-else class="align-right">
2024-04-15 11:29:17 +00:00
<Button
type="primary"
size="large"
:loading="!isTableCreated"
@click="openTable()"
>
{{
isTableCreation
? $t('importFileModal.openCreatedTable')
: $t('importFileModal.showTable')
}}
2024-04-15 11:29:17 +00:00
</Button>
</div>
2022-07-25 14:28:47 +00:00
</template>
<template v-if="!isTableCreation" #sidebar>
<div class="import-modal__field-mapping">
<div v-if="header.length > 0" class="import-modal__field-mapping-body">
2022-07-25 14:28:47 +00:00
<h3>{{ $t('importFileModal.fieldMappingTitle') }}</h3>
<p>{{ $t('importFileModal.fieldMappingDescription') }}</p>
<div v-for="(head, index) in header" :key="head" class="control">
<label class="control__label control__label--small">
{{ head }}
</label>
<Dropdown v-model="mapping[index]">
<DropdownItem name="Skip" :value="0" icon="ban" />
<DropdownItem
v-for="field in availableFields"
:key="field.id"
:name="field.name"
:value="field.id"
:icon="field._.type.iconClass"
:disabled="
selectedFields.includes(field.id) &&
field.id !== mapping[index]
"
/>
</Dropdown>
</div>
</div>
<div v-else class="import-modal__field-mapping--empty">
2023-09-28 13:39:41 +00:00
<i class="import-modal__field-mapping-empty-icon iconoir-shuffle" />
<div class="import-modal__field-mapping-empty-text">
2022-07-25 14:28:47 +00:00
{{ $t('importFileModal.selectImportMessage') }}
</div>
</div>
</div>
2024-01-11 09:36:06 +00:00
<div class="modal__actions">
<a class="modal__close" @click="hide()">
<i class="iconoir-cancel"></i>
</a>
</div>
2022-07-25 14:28:47 +00:00
</template>
</Modal>
</template>
<script>
import VueRouter from 'vue-router'
2022-07-25 14:28:47 +00:00
import { clone } from '@baserow/modules/core/utils/object'
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import jobProgress from '@baserow/modules/core/mixins/jobProgress'
import TableService from '@baserow/modules/database/services/table'
import {
uuid,
getNextAvailableNameInSequence,
} from '@baserow/modules/core/utils/string'
import SimpleGrid from '@baserow/modules/database/components/view/grid/SimpleGrid'
2022-07-25 14:28:47 +00:00
import _ from 'lodash'
import { ResponseErrorMessage } from '@baserow/modules/core/plugins/clientHandler'
import TableForm from './TableForm'
export default {
name: 'ImportFileModal',
components: { TableForm, SimpleGrid },
2022-07-25 14:28:47 +00:00
mixins: [modal, error, jobProgress],
props: {
database: {
type: Object,
required: true,
},
table: {
type: Object,
required: false,
default: null,
},
fields: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
importer: '',
uploadProgressPercentage: 0,
importState: null,
showProgressBar: false,
header: [],
mapping: {},
2022-09-15 08:59:42 +00:00
getData: null,
previewData: [],
dataLoaded: false,
2022-07-25 14:28:47 +00:00
}
},
computed: {
isTableCreation() {
return this.table === null
},
isTableCreated() {
if (!this.job?.table_id) {
return false
}
return this.database.tables.some(({ id }) => id === this.job.table_id)
},
canBeSubmitted() {
return (
this.isTableCreation ||
(this.importer &&
Object.values(this.mapping).some(
(value) => this.fieldIndexMap[value] !== undefined
))
)
},
fieldTypes() {
return this.$registry.getAll('field')
},
2022-09-15 08:59:42 +00:00
fileFields() {
return this.header.map((header, index) => ({
type: 'text',
name: header,
id: uuid(),
order: index,
}))
2022-07-25 14:28:47 +00:00
},
/**
* All writable fields.
*/
writableFields() {
return this.fields.filter(
({ type }) => !this.fieldTypes[type].getIsReadOnly()
)
},
2022-09-15 08:59:42 +00:00
/**
* Map beetween the field id and its index in the array.
*/
fieldIndexMap() {
return Object.fromEntries(
this.writableFields.map((field, index) => [field.id, index])
)
},
2022-07-25 14:28:47 +00:00
/**
* All writable fields that can be imported into
*/
availableFields() {
return this.writableFields.filter(({ type }) =>
this.fieldTypes[type].getCanImport()
)
},
2022-09-15 08:59:42 +00:00
fieldMapping() {
return Object.entries(this.mapping)
.filter(
([, targetFieldId]) =>
!!targetFieldId ||
// Check if we have an id from a removed field
this.fieldIndexMap[targetFieldId] !== undefined
)
.map(([importIndex, targetFieldId]) => {
return [importIndex, this.fieldIndexMap[targetFieldId]]
})
},
// Template row with default values
defaultRow() {
return this.writableFields.map((field) =>
this.fieldTypes[field.type].getEmptyValue(field)
)
},
previewFileData() {
return this.previewData.map((row) => {
const newRow = Object.fromEntries(
this.fileFields.map((field, index) => [
`field_${field.id}`,
`${row[index]}`,
])
)
newRow.id = uuid()
return newRow
})
},
previewImportData() {
return this.previewData.map((row) => {
const newRow = Object.fromEntries(
this.fieldMapping.map(([importIndex, fieldIndex]) => {
const field = this.writableFields[fieldIndex]
return [
`field_${field.id}`,
this.fieldTypes[field.type].prepareValueForPaste(
field,
`${row[importIndex]}`,
row[importIndex]
),
]
})
)
newRow.id = uuid()
return newRow
})
},
2022-07-25 14:28:47 +00:00
/**
* Fields that are mapped to a column
*/
selectedFields() {
return Object.values(this.mapping)
},
progressPercentage() {
switch (this.state) {
case null:
return 0
case 'preparingData':
return 1
case 'uploading':
// 10% -> 50%
return (this.uploadProgressPercentage / 100) * 40 + 10
default:
// 50% -> 100%
return 50 + this.job.progress_percentage / 2
}
},
state() {
if (this.job === null) {
return this.importState
} else {
return this.job.state
}
},
importInProgress() {
return this.state !== null && !this.jobIsFinished && !this.error.visible
},
importerTypes() {
return this.$registry.getAll('importer')
},
importerComponent() {
return this.importer === ''
? null
: this.$registry.get('importer', this.importer).getFormComponent()
},
humanReadableState() {
switch (this.state) {
case null:
return ''
case 'preparingData':
return this.$t('importFileModal.preparing')
case 'uploading':
if (this.uploadProgressPercentage === 100) {
return this.$t('job.statePending')
} else {
return this.$t('importFileModal.uploading')
}
default:
return this.jobHumanReadableState
}
},
errorReport() {
if (this.job && Object.keys(this.job.report.failing_rows).length > 0) {
return Object.keys(this.job.report.failing_rows)
.map((key) => parseInt(key, 10) + 1)
.sort((a, b) => a - b)
} else {
return []
}
},
},
beforeDestroy() {
this.stopPollIfRunning()
},
methods: {
getDefaultName() {
const excludeNames = this.database.tables.map((table) => table.name)
const baseName = this.$t('importFileModal.defaultName')
return getNextAvailableNameInSequence(baseName, excludeNames)
},
2022-07-25 14:28:47 +00:00
reset(full = true) {
this.job = null
this.uploadProgressPercentage = 0
if (full) {
this.header = []
this.importState = null
this.mapping = {}
2022-09-15 08:59:42 +00:00
this.getData = null
this.previewData = []
this.dataLoaded = false
2022-07-25 14:28:47 +00:00
}
this.hideError()
},
2022-09-15 08:59:42 +00:00
onData({ header, previewData }) {
this.header = header
this.previewData = previewData
this.mapping = Object.fromEntries(
header.map((name, index) => {
const foundField = this.availableFields.find(
({ name: fieldName }) => fieldName === name
)
return [index, foundField ? foundField.id : 0]
})
)
this.dataLoaded = header.length > 0 || previewData.length > 0
2022-09-15 08:59:42 +00:00
},
onGetData(getData) {
this.getData = getData
},
2022-07-25 14:28:47 +00:00
onHeader(header) {
this.header = header
this.mapping = Object.fromEntries(
header.map((name, index) => {
const foundField = this.availableFields.find(
({ name: fieldName }) => fieldName === name
)
return [index, foundField ? foundField.id : 0]
})
)
},
/**
* When the form is submitted we try to extract the initial data and first row
* header setting from the values. An importer could have added those, but they
* need to be removed from the values.
*/
async submitted(formValues) {
this.showProgressBar = false
this.reset(false)
let data = null
const values = { ...formValues }
2022-09-15 08:59:42 +00:00
if (typeof this.getData === 'function') {
2022-07-25 14:28:47 +00:00
try {
this.showProgressBar = true
this.importState = 'preparingData'
await this.$ensureRender()
2022-09-15 08:59:42 +00:00
data = await this.getData()
2022-07-25 14:28:47 +00:00
if (!this.isTableCreation) {
const fieldMapping = Object.entries(this.mapping)
.filter(
([, targetFieldId]) =>
!!targetFieldId ||
// Check if we have an id from a removed field
this.fieldIndexMap[targetFieldId] !== undefined
)
.map(([importIndex, targetFieldId]) => {
return [importIndex, this.fieldIndexMap[targetFieldId]]
})
// Template row with default values
const defaultRow = this.writableFields.map((field) =>
this.fieldTypes[field.type].getEmptyValue(field)
)
// Precompute the prepare value function for each field
const prepareValueByField = this.writableFields.map(
(field) => (value) =>
this.fieldTypes[field.type].prepareValueForUpdate(
field,
2022-09-15 08:59:42 +00:00
this.fieldTypes[field.type].prepareValueForPaste(
field,
`${value}`,
value
)
2022-07-25 14:28:47 +00:00
)
)
// Processes the data by chunk to avoid UI freezes
const result = []
for (const chunk of _.chunk(data, 1000)) {
result.push(
chunk.map((row) => {
const newRow = clone(defaultRow)
fieldMapping.forEach(([importIndex, targetIndex]) => {
newRow[targetIndex] = prepareValueByField[targetIndex](
row[importIndex]
)
})
2022-08-05 13:52:33 +00:00
2022-07-25 14:28:47 +00:00
return newRow
})
)
await this.$ensureRender()
}
data = result.flat()
} else {
// Add the header in case of table creation
data = [this.header, ...data]
}
} catch (error) {
this.reset()
this.handleError(error, 'application')
}
}
this.importState = 'uploading'
const onUploadProgress = ({ loaded, total }) =>
(this.uploadProgressPercentage = (loaded / total) * 100)
try {
if (data && data.length > 0) {
this.showProgressBar = true
}
if (this.isTableCreation) {
const { data: job } = await TableService(this.$client).create(
this.database.id,
values,
data,
true,
{
onUploadProgress,
}
)
this.startJobPoller(job)
} else {
const { data: job } = await TableService(this.$client).importData(
this.table.id,
data,
{
onUploadProgress,
}
)
this.startJobPoller(job)
}
} catch (error) {
this.stopPollAndHandleError(error, {
ERROR_MAX_JOB_COUNT_EXCEEDED: new ResponseErrorMessage(
this.$t('job.errorJobAlreadyRunningTitle'),
this.$t('job.errorJobAlreadyRunningDescription')
),
})
}
},
getCustomHumanReadableJobState(jobState) {
const translations = {
'row-import-creation': this.$t('importFileModal.stateRowCreation'),
'row-import-validation': this.$t('importFileModal.statePreValidation'),
'import-create-table': this.$t('importFileModal.stateCreateTable'),
}
return translations[jobState]
},
2023-11-08 21:31:28 +00:00
async openTable() {
2022-07-25 14:28:47 +00:00
// Redirect to the newly created table.
2023-11-08 21:31:28 +00:00
try {
await this.$nuxt.$router.push({
name: 'database-table',
params: {
databaseId: this.database.id,
tableId: this.job.table_id,
},
})
2023-11-08 21:31:28 +00:00
} catch (error) {
// When redirecting to the `database-table`, it can happen that it redirects
// to another view. For some reason, this is causing the router throw an
// error. In our case, it's perfectly fine, so we're suppressing this error
// here. More information:
// https://stackoverflow.com/questions/62223195/vue-router-uncaught-in-promise-
// error-redirected-from-login-to-via-a
const { isNavigationFailure, NavigationFailureType } = VueRouter
if (!isNavigationFailure(error, NavigationFailureType.redirected)) {
throw error
}
}
2023-11-08 21:49:49 +00:00
this.hide()
2022-07-25 14:28:47 +00:00
},
async onJobDone() {
if (this.isTableCreation) {
// Let's add the table to the store...
const { data: table } = await TableService(this.$client).get(
this.job.table_id
)
await this.$store.dispatch('table/forceUpsert', {
2022-07-25 14:28:47 +00:00
database: this.database,
data: table,
})
if (this.errorReport.length === 0) {
2023-11-08 21:31:28 +00:00
await this.openTable()
}
2022-07-25 14:28:47 +00:00
} else {
this.$bus.$emit('table-refresh', {
tableId: this.job.table_id,
})
if (this.errorReport.length === 0) {
this.hide()
}
2022-07-25 14:28:47 +00:00
}
},
onJobFailed() {
const error = new ResponseErrorMessage(
this.$t('importFileModal.importError'),
this.job.human_readable_error
)
this.stopPollAndHandleError(error)
},
onJobPollingError(error) {
this.stopPollAndHandleError(error)
},
stopPollAndHandleError(error, specificErrorMap = null) {
this.stopPollIfRunning()
error.handler
? this.handleError(error, 'application', specificErrorMap)
: this.showError(error)
},
},
}
</script>