1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Resolve "Import existing data from CSV, Excel etc"

This commit is contained in:
Bram Wiepjes 2020-10-18 15:30:25 +00:00
parent f74f019f79
commit 3719f17042
27 changed files with 1141 additions and 30 deletions

View file

@ -1,4 +1,4 @@
from rest_framework.status import HTTP_404_NOT_FOUND
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
ERROR_TABLE_DOES_NOT_EXIST = (
@ -6,3 +6,8 @@ ERROR_TABLE_DOES_NOT_EXIST = (
HTTP_404_NOT_FOUND,
'The requested table does not exist.'
)
ERROR_INVALID_INITIAL_TABLE_DATA = (
'ERROR_INVALID_INITIAL_TABLE_DATA',
HTTP_400_BAD_REQUEST,
'The provided table data must at least contain one row and one column.'
)

View file

@ -17,7 +17,37 @@ class TableSerializer(serializers.ModelSerializer):
}
class TableCreateUpdateSerializer(serializers.ModelSerializer):
class TableCreateSerializer(serializers.ModelSerializer):
data = serializers.ListField(
min_length=1,
child=serializers.ListField(
child=serializers.CharField(
help_text='The value of the cell.',
allow_blank=True
),
help_text='The row containing all the values.'
),
default=None,
help_text='A list of rows that needs to be created as initial table data. If '
'not provided some example data is going to be created.'
)
first_row_header = serializers.BooleanField(
default=False,
help_text='Indicates if the first provided row is the header. If true the '
'field names are going to be the values of the first row. Otherwise '
'they will be called "Column N"'
)
class Meta:
model = Table
fields = ('name', 'data', 'first_row_header')
extra_kwargs = {
'data': {'required': False},
'first_row_header': {'required': False},
}
class TableUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = Table
fields = ('name',)

View file

@ -16,10 +16,12 @@ from baserow.core.handler import CoreHandler
from baserow.contrib.database.models import Database
from baserow.contrib.database.table.models import Table
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.table.exceptions import TableDoesNotExist
from baserow.contrib.database.table.exceptions import (
TableDoesNotExist, InvalidInitialTableData
)
from .serializers import TableSerializer, TableCreateUpdateSerializer
from .errors import ERROR_TABLE_DOES_NOT_EXIST
from .serializers import TableSerializer, TableCreateSerializer, TableUpdateSerializer
from .errors import ERROR_TABLE_DOES_NOT_EXIST, ERROR_INVALID_INITIAL_TABLE_DATA
class TablesView(APIView):
@ -83,7 +85,7 @@ class TablesView(APIView):
'`database_id` parameter if the authorized user has access to the '
'database\'s group.'
),
request=TableCreateUpdateSerializer,
request=TableCreateSerializer,
responses={
200: TableSerializer,
400: get_error_schema([
@ -95,9 +97,10 @@ class TablesView(APIView):
@transaction.atomic
@map_exceptions({
ApplicationDoesNotExist: ERROR_APPLICATION_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
InvalidInitialTableData: ERROR_INVALID_INITIAL_TABLE_DATA
})
@validate_body(TableCreateUpdateSerializer)
@validate_body(TableCreateSerializer)
def post(self, request, data, database_id):
"""Creates a new table in a database."""
@ -106,7 +109,11 @@ class TablesView(APIView):
base_queryset=Database.objects
)
table = TableHandler().create_table(
request.user, database, fill_initial=True, name=data['name'])
request.user,
database,
fill_example=True,
**data
)
serializer = TableSerializer(table)
return Response(serializer.data)
@ -161,7 +168,7 @@ class TableView(APIView):
'Updates the existing table if the authorized user has access to the '
'related database\'s group.'
),
request=TableCreateUpdateSerializer,
request=TableUpdateSerializer,
responses={
200: TableSerializer,
400: get_error_schema([
@ -175,7 +182,7 @@ class TableView(APIView):
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
})
@validate_body(TableCreateUpdateSerializer)
@validate_body(TableUpdateSerializer)
def patch(self, request, data, table_id):
"""Updates the values a table instance."""

View file

@ -1,2 +1,6 @@
class TableDoesNotExist(Exception):
"""Raised when trying to get a table that doesn't exist."""
class InvalidInitialTableData(Exception):
"""Raised when the provided initial table data does not contain a column or row."""

View file

@ -12,7 +12,7 @@ from baserow.contrib.database.fields.field_types import (
)
from .models import Table
from .exceptions import TableDoesNotExist
from .exceptions import TableDoesNotExist, InvalidInitialTableData
class TableHandler:
@ -41,7 +41,8 @@ class TableHandler:
return table
def create_table(self, user, database, fill_initial=False, **kwargs):
def create_table(self, user, database, fill_example=False, data=None,
first_row_header=True, **kwargs):
"""
Creates a new table and a primary text field.
@ -49,9 +50,16 @@ class TableHandler:
:type user: User
:param database: The database that the table instance belongs to.
:type database: Database
:param fill_initial: Indicates whether an initial view, some fields and
some rows should be added.
:type fill_initial: bool
:param fill_example: Indicates whether an initial view, some fields and
some rows should be added. Works only if no data is provided.
:type fill_example: bool
:param data: A list containing all the rows that need to be inserted is
expected. All the values of the row are going to be converted to a string
and will be inserted in the database.
:type: initial_data: None or list[list[str]
:param first_row_header: Indicates if the first row are the fields. The names
of these rows are going to be used as fields.
:type first_row_header: bool
:param kwargs: The fields that need to be set upon creation.
:type kwargs: object
:raises UserNotInGroupError: When the user does not belong to the related group.
@ -62,13 +70,30 @@ class TableHandler:
if not database.group.has_user(user):
raise UserNotInGroupError(user, database.group)
if data is not None:
fields, data = self.normalize_initial_table_data(data, first_row_header)
table_values = extract_allowed(kwargs, ['name'])
last_order = Table.get_last_order(database)
table = Table.objects.create(database=database, order=last_order,
**table_values)
# Create a primary text field for the table.
TextField.objects.create(table=table, order=0, primary=True, name='Name')
if data is not None:
# If the initial data has been provided we will create those fields before
# creating the model so that we the whole table schema is created right
# away.
for index, name in enumerate(fields):
fields[index] = TextField.objects.create(
table=table,
order=index,
primary=index == 0,
name=name
)
else:
# If no initial data is provided we want to create a primary text field for
# the table.
TextField.objects.create(table=table, order=0, primary=True, name='Name')
# Create the table schema in the database database.
connection = connections[settings.USER_TABLE_DATABASE]
@ -76,15 +101,81 @@ class TableHandler:
model = table.get_model()
schema_editor.create_model(model)
if fill_initial:
self.fill_initial_table_data(user, table)
if data is not None:
self.fill_initial_table_data(user, table, fields, data, model)
elif fill_example:
self.fill_example_table_data(user, table)
return table
def fill_initial_table_data(self, user, table):
def normalize_initial_table_data(self, data, first_row_header):
"""
Fills the table with some initial data. A new table is expected that already
has the a primary field named 'name'.
Normalizes the provided initial table data. The amount of columns will be made
equal for each row. The header and the rows will also be separated.
:param data: A list containing all the provided rows.
:type data: list
:param first_row_header: Indicates if the first row is the header. For each
of these header columns a field is going to be created.
:type first_row_header: bool
:return: A list containing the field names and a list containing all the rows.
:rtype: list, list
:raises InvalidInitialTableData: When the data doesn't contain a column or row.
"""
if len(data) == 0:
raise InvalidInitialTableData('At least one row should be provided.')
largest_column_count = len(max(data, key=len))
if largest_column_count == 0:
raise InvalidInitialTableData('At least one column should be provided.')
fields = data.pop(0) if first_row_header else []
for i in range(len(fields), largest_column_count):
fields.append(f'Field {i + 1}')
for row in data:
for i in range(len(row), largest_column_count):
row.append('')
return fields, data
def fill_initial_table_data(self, user, table, fields, data, model):
"""
Fills the provided table with the normalized data that needs to be created upon
creation of the table.
:param user: The user on whose behalf the table is created.
:type user: User`
:param table: The newly created table where the initial data has to be inserted
into.
:type table: Table
:param fields: A list containing the field names.
:type fields: list
:param data: A list containing the rows that need to be inserted.
:type data: list
:param model: The generated table model of the table that needs to be filled
with initial data.
:type model: TableModel
"""
ViewHandler().create_view(user, table, GridViewType.type, name='Grid')
bulk_data = [
model(**{
f'field_{fields[index].id}': str(value)
for index, value in enumerate(row)
})
for row in data
]
model.objects.bulk_create(bulk_data)
def fill_example_table_data(self, user, table):
"""
Fills the table with some initial example data. A new table is expected that
already has the a primary field named 'name'.
:param user: The user on whose behalf the table is filled.
:type: user: User

View file

@ -4,6 +4,7 @@ from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NO
from django.shortcuts import reverse
from baserow.contrib.database.fields.models import TextField
from baserow.contrib.database.table.models import Table
@ -92,6 +93,170 @@ def test_create_table(api_client, data_fixture):
assert response.json()['error'] == 'ERROR_APPLICATION_DOES_NOT_EXIST'
@pytest.mark.django_db
def test_create_table_with_data(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
database = data_fixture.create_database_application(user=user)
url = reverse('api:database:tables:list', kwargs={'database_id': database.id})
response = api_client.post(
url,
{
'name': 'Test 1',
'data': []
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert response_json['detail']['data'][0]['code'] == 'min_length'
url = reverse('api:database:tables:list', kwargs={'database_id': database.id})
response = api_client.post(
url,
{
'name': 'Test 1',
'data': [[]]
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_INVALID_INITIAL_TABLE_DATA'
url = reverse('api:database:tables:list', kwargs={'database_id': database.id})
response = api_client.post(
url,
{
'name': 'Test 1',
'data': [
['A', 'B', 'C', 'D'],
['1-1', '1-2', '1-3', '1-4', '1-5'],
['2-1', '2-2', '2-3'],
['3-1', '3-2'],
],
'first_row_header': True
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
table = Table.objects.get(id=response_json['id'])
text_fields = TextField.objects.filter(table=table)
assert text_fields[0].name == 'A'
assert text_fields[1].name == 'B'
assert text_fields[2].name == 'C'
assert text_fields[3].name == 'D'
assert text_fields[4].name == 'Field 5'
model = table.get_model()
results = model.objects.all()
assert results.count() == 3
assert getattr(results[0], f'field_{text_fields[0].id}') == '1-1'
assert getattr(results[0], f'field_{text_fields[1].id}') == '1-2'
assert getattr(results[0], f'field_{text_fields[2].id}') == '1-3'
assert getattr(results[0], f'field_{text_fields[3].id}') == '1-4'
assert getattr(results[0], f'field_{text_fields[4].id}') == '1-5'
assert getattr(results[1], f'field_{text_fields[0].id}') == '2-1'
assert getattr(results[1], f'field_{text_fields[1].id}') == '2-2'
assert getattr(results[1], f'field_{text_fields[2].id}') == '2-3'
assert getattr(results[1], f'field_{text_fields[3].id}') == ''
assert getattr(results[1], f'field_{text_fields[4].id}') == ''
assert getattr(results[2], f'field_{text_fields[0].id}') == '3-1'
assert getattr(results[2], f'field_{text_fields[1].id}') == '3-2'
assert getattr(results[2], f'field_{text_fields[2].id}') == ''
assert getattr(results[2], f'field_{text_fields[3].id}') == ''
assert getattr(results[2], f'field_{text_fields[4].id}') == ''
url = reverse('api:database:tables:list', kwargs={'database_id': database.id})
response = api_client.post(
url,
{
'name': 'Test 2',
'data': [
['1-1'],
['2-1', '2-2', '2-3'],
['3-1', '3-2'],
],
'first_row_header': False
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
table = Table.objects.get(id=response_json['id'])
text_fields = TextField.objects.filter(table=table)
assert text_fields[0].name == 'Field 1'
assert text_fields[1].name == 'Field 2'
assert text_fields[2].name == 'Field 3'
model = table.get_model()
results = model.objects.all()
assert results.count() == 3
assert getattr(results[0], f'field_{text_fields[0].id}') == '1-1'
assert getattr(results[0], f'field_{text_fields[1].id}') == ''
assert getattr(results[0], f'field_{text_fields[2].id}') == ''
assert getattr(results[1], f'field_{text_fields[0].id}') == '2-1'
assert getattr(results[1], f'field_{text_fields[1].id}') == '2-2'
assert getattr(results[1], f'field_{text_fields[2].id}') == '2-3'
assert getattr(results[2], f'field_{text_fields[0].id}') == '3-1'
assert getattr(results[2], f'field_{text_fields[1].id}') == '3-2'
url = reverse('api:database:tables:list', kwargs={'database_id': database.id})
response = api_client.post(
url,
{
'name': 'Test 2',
'data': [
[
'TEst 1', '10.00', 'Falsea"""', 'a"a"a"a"a,', 'a', 'a', '',
'/w. r/awr', '', ''
],
],
'first_row_header': True
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
table = Table.objects.get(id=response_json['id'])
text_fields = TextField.objects.filter(table=table)
assert text_fields[0].name == 'TEst 1'
assert text_fields[1].name == '10.00'
assert text_fields[2].name == 'Falsea"""'
assert text_fields[3].name == 'a"a"a"a"a,'
assert text_fields[4].name == 'a'
assert text_fields[5].name == 'a'
assert text_fields[6].name == ''
assert text_fields[7].name == '/w. r/awr'
assert text_fields[8].name == ''
assert text_fields[9].name == ''
model = table.get_model()
results = model.objects.all()
assert results.count() == 0
@pytest.mark.django_db
def test_get_table(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()

View file

@ -5,7 +5,9 @@ from django.db import connection
from baserow.core.exceptions import UserNotInGroupError
from baserow.contrib.database.table.models import Table
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.table.exceptions import TableDoesNotExist
from baserow.contrib.database.table.exceptions import (
TableDoesNotExist, InvalidInitialTableData
)
from baserow.contrib.database.fields.models import (
TextField, LongTextField, BooleanField
)
@ -69,12 +71,12 @@ def test_create_database_table(data_fixture):
@pytest.mark.django_db
def test_fill_initial_table_data(data_fixture):
def test_fill_example_table_data(data_fixture):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table_handler = TableHandler()
table_handler.create_table(user, database, fill_initial=True, name='Table 1')
table_handler.create_table(user, database, fill_example=True, name='Table 1')
assert Table.objects.all().count() == 1
assert GridView.objects.all().count() == 1
@ -84,6 +86,88 @@ def test_fill_initial_table_data(data_fixture):
assert GridViewFieldOptions.objects.all().count() == 2
@pytest.mark.django_db
def test_fill_table_with_initial_data(data_fixture):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table_handler = TableHandler()
with pytest.raises(InvalidInitialTableData):
table_handler.create_table(user, database, name='Table 1', data=[])
with pytest.raises(InvalidInitialTableData):
table_handler.create_table(user, database, name='Table 1', data=[[]])
data = [
['A', 'B', 'C', 'D'],
['1-1', '1-2', '1-3', '1-4', '1-5'],
['2-1', '2-2', '2-3'],
['3-1', '3-2'],
]
table = table_handler.create_table(user, database, name='Table 1', data=data,
first_row_header=True)
text_fields = TextField.objects.filter(table=table)
assert text_fields[0].name == 'A'
assert text_fields[1].name == 'B'
assert text_fields[2].name == 'C'
assert text_fields[3].name == 'D'
assert text_fields[4].name == 'Field 5'
assert GridView.objects.all().count() == 1
model = table.get_model()
results = model.objects.all()
assert getattr(results[0], f'field_{text_fields[0].id}') == '1-1'
assert getattr(results[0], f'field_{text_fields[1].id}') == '1-2'
assert getattr(results[0], f'field_{text_fields[2].id}') == '1-3'
assert getattr(results[0], f'field_{text_fields[3].id}') == '1-4'
assert getattr(results[0], f'field_{text_fields[4].id}') == '1-5'
assert getattr(results[1], f'field_{text_fields[0].id}') == '2-1'
assert getattr(results[1], f'field_{text_fields[1].id}') == '2-2'
assert getattr(results[1], f'field_{text_fields[2].id}') == '2-3'
assert getattr(results[1], f'field_{text_fields[3].id}') == ''
assert getattr(results[1], f'field_{text_fields[4].id}') == ''
assert getattr(results[2], f'field_{text_fields[0].id}') == '3-1'
assert getattr(results[2], f'field_{text_fields[1].id}') == '3-2'
assert getattr(results[2], f'field_{text_fields[2].id}') == ''
assert getattr(results[2], f'field_{text_fields[3].id}') == ''
assert getattr(results[2], f'field_{text_fields[4].id}') == ''
data = [
['1-1'],
['2-1', '2-2', '2-3'],
['3-1', '3-2'],
]
table = table_handler.create_table(user, database, name='Table 2', data=data,
first_row_header=False)
text_fields = TextField.objects.filter(table=table)
assert text_fields[0].name == 'Field 1'
assert text_fields[1].name == 'Field 2'
assert text_fields[2].name == 'Field 3'
assert GridView.objects.all().count() == 2
model = table.get_model()
results = model.objects.all()
assert getattr(results[0], f'field_{text_fields[0].id}') == '1-1'
assert getattr(results[0], f'field_{text_fields[1].id}') == ''
assert getattr(results[0], f'field_{text_fields[2].id}') == ''
assert getattr(results[1], f'field_{text_fields[0].id}') == '2-1'
assert getattr(results[1], f'field_{text_fields[1].id}') == '2-2'
assert getattr(results[1], f'field_{text_fields[2].id}') == '2-3'
assert getattr(results[2], f'field_{text_fields[0].id}') == '3-1'
assert getattr(results[2], f'field_{text_fields[1].id}') == '3-2'
@pytest.mark.django_db
def test_update_database_table(data_fixture):
user = data_fixture.create_user()

View file

@ -6,6 +6,7 @@
* Fixed error when there is no view.
* Added Ubuntu installation guide documentation.
* Added Email field.
* Added importer abstraction including a CSV and tabular paste importer.
## Released (2020-10-06)

View file

@ -12,7 +12,8 @@
"include",
"mixin",
"return",
"extend"
"extend",
"for"
]
}
]

View file

@ -36,3 +36,7 @@
@import 'select_row_modal';
@import 'filters';
@import 'sortings';
@import 'choice_items';
@import 'grid';
@import 'table_preview';
@import 'file_upload';

View file

@ -0,0 +1,57 @@
.choice-items {
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0 0 -18px 0;
padding: 0;
width: 100%;
li {
flex: 0 0 48%;
margin-bottom: 10px;
&:not(:nth-child(2n+2)) {
margin-right: 4%;
}
}
}
.choice-items__link {
@extend %ellipsis;
position: relative;
display: block;
padding: 0 48px 0 20px;
line-height: 44px;
font-size: 14px;
color: $color-neutral-600;
border-radius: 3px;
&:hover {
text-decoration: none;
background-color: $color-neutral-100;
color: $color-primary-900;
}
&.active {
color: $color-primary-900;
background-color: $color-primary-100;
&::after {
@extend .fas;
content: fa-content($fa-var-check-circle);
width: 32px;
line-height: 20px;
text-align: center;
color: $color-success-500;
margin-top: -10px;
@include absolute(50%, 10px, auto, auto);
}
}
}
.choice-items__icon {
margin-right: 10px;
}

View file

@ -0,0 +1,8 @@
.file-upload {
display: flex;
align-items: center;
}
.file-upload__button {
margin-right: 20px;
}

View file

@ -19,6 +19,13 @@
margin-right: 6px;
}
.control__description {
font-size: 13px;
line-height: 160%;
color: $color-neutral-600;
margin-bottom: 16px;
}
.control__context {
color: $color-primary-900;
margin-left: 6px;
@ -52,6 +59,13 @@
}
}
.textarea--modal {
resize: vertical;
height: 22px * 6;
line-height: 22px;
font-size: 13px;
}
.input__with-icon {
position: relative;

View file

@ -0,0 +1,27 @@
.row {
box-sizing: border-box;
display: flex;
flex: 0 1 auto;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -#{$grid-width};
}
.col {
box-sizing: border-box;
flex-grow: 1;
flex-basis: 0;
max-width: 100%;
padding: 0 $grid-width;
}
@for $i from 1 through $grid-columns {
.col-#{$i} {
flex-basis: 100% / $grid-columns * $i;
max-width: 100% / $grid-columns * $i;
}
.col-offset-#{$i} {
margin-left: 100% / $grid-columns * $i;
}
}

View file

@ -0,0 +1,37 @@
.table-preview__container {
overflow-x: auto;
max-width: 100%;
border: solid 1px $color-neutral-300;
}
.table-preview {
background-color: $white;
}
.table-preview__row {
display: flex;
flex-wrap: nowrap;
}
.table-preview__column {
@extend %ellipsis;
flex: 0 0 200px;
line-height: 33px;
height: 33px;
padding: 0 10px;
border-right: solid 1px $color-neutral-200;
.table-preview__head & {
background-color: $color-neutral-100;
}
.table-preview__row:not(:last-child) & {
border-bottom: solid 1px $color-neutral-200;
}
}
.table-preview__more {
line-height: 33px;
padding: 0 10px;
}

View file

@ -3,6 +3,9 @@ $logo-font-stack: 'Montserrat', sans-serif;
$scrollbar-size: 6px;
$grid-columns: 12;
$grid-width: 20px;
$white: #fff;
$black: #000;

View file

@ -3,6 +3,39 @@
<h2 class="box__title">Create new table</h2>
<Error :error="error"></Error>
<TableForm ref="tableForm" @submitted="submitted">
<div class="control">
<label class="control__label">
Would you like to import existing data?
</label>
<div class="control__elements">
<ul class="choice-items">
<li>
<a
class="choice-items__link"
:class="{ active: importer === '' }"
@click="importer = ''"
>
<i class="choice-items__icon fas fa-clone"></i>
Start with an empty table
</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"
>
<i
class="choice-items__icon fas"
:class="'fa-' + importerType.iconClass"
></i>
{{ importerType.name }}
</a>
</li>
</ul>
</div>
</div>
<component :is="importerComponent" />
<div class="actions">
<div class="align-right">
<button
@ -37,17 +70,52 @@ export default {
data() {
return {
loading: false,
importer: '',
}
},
computed: {
importerTypes() {
return this.$registry.getAll('importer')
},
importerComponent() {
return this.importer === ''
? null
: this.$registry.get('importer', this.importer).getFormComponent()
},
},
methods: {
hide(...args) {
modal.methods.hide.call(this, ...args)
this.importer = ''
},
/**
* 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(values) {
this.loading = true
this.hideError()
let firstRowHeader = false
let data = null
if (Object.prototype.hasOwnProperty.call(values, 'firstRowHeader')) {
firstRowHeader = values.firstRowHeader
delete values.firstRowHeader
}
if (Object.prototype.hasOwnProperty.call(values, 'data')) {
data = JSON.parse(values.data)
delete values.data
}
try {
const table = await this.$store.dispatch('table/create', {
database: this.application,
values,
initialData: data,
firstRowHeader,
})
this.loading = false
this.hide()

View file

@ -0,0 +1,194 @@
<template>
<div>
<div class="control">
<label class="control__label">
Choose CSV file
</label>
<div class="control__description">
You can import an existing CSV by uploading the .CSV file with tabular
data. Most spreadsheet applications will allow you to export your
spreadsheet as a .CSV file.
</div>
<div class="control__elements">
<div class="file-upload">
<input
v-show="false"
ref="file"
type="file"
accept=".csv"
@change="select($event)"
/>
<a
class="button button--large button--ghost file-upload__button"
@click.prevent="$refs.file.click($event)"
>
<i class="fas fa-cloud-upload-alt"></i>
Choose CSV file
</a>
<div class="file-upload__file">{{ filename }}</div>
</div>
<div v-if="$v.filename.$error" class="error">
This field is required.
</div>
</div>
</div>
<div v-if="filename !== ''" class="row">
<div class="col col-6">
<div class="control">
<label class="control__label">
First row is header
</label>
<div class="control__elements">
<Checkbox v-model="values.firstRowHeader" @input="reload()"
>yes</Checkbox
>
</div>
</div>
</div>
<div class="col col-6">
<div class="control">
<label class="control__label">
Column separator
</label>
<div class="control__elements">
<Dropdown v-model="columnSeparator" @input="reload()">
<DropdownItem name="auto detect" value="auto"></DropdownItem>
<DropdownItem name="," value=","></DropdownItem>
<DropdownItem name=";" value=";"></DropdownItem>
<DropdownItem name="|" value="|"></DropdownItem>
<DropdownItem name="<tab>" value="\t"></DropdownItem>
<DropdownItem
name="record separator (30)"
:value="String.fromCharCode(30)"
></DropdownItem>
<DropdownItem
name="unit separator (31)"
:value="String.fromCharCode(31)"
></DropdownItem>
</Dropdown>
</div>
</div>
</div>
</div>
<div v-if="error !== ''" class="alert alert--error alert--has-icon">
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Something went wrong</div>
<p class="alert__content">
{{ error }}
</p>
</div>
<TableImporterPreview
v-if="error === '' && Object.keys(preview).length !== 0"
:preview="preview"
></TableImporterPreview>
</div>
</template>
<script>
import Papa from 'papaparse'
import { required } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form'
import importer from '@baserow/modules/database/mixins/importer'
import TableImporterPreview from '@baserow/modules/database/components/table/TableImporterPreview'
export default {
name: 'TableCSVImporter',
components: { TableImporterPreview },
mixins: [form, importer],
data() {
return {
values: {
data: '',
firstRowHeader: true,
},
filename: '',
columnSeparator: 'auto',
error: '',
rawData: null,
preview: {},
}
},
validations: {
values: {
data: { required },
},
filename: { required },
},
methods: {
/**
* Method that is called when a file has been chosen. It will check if the file is
* not larger than 15MB. Otherwise it will take a long time and possibly a crash
* if so many entries have to be loaded into memory. If the file is valid, the
* contents will be loaded into memory and the reload method will be called which
* parses the content.
*/
select(event) {
if (event.target.files.length === 0) {
return
}
const file = event.target.files[0]
const maxSize = 1024 * 1024 * 15
if (file.size > maxSize) {
this.filename = ''
this.values.data = ''
this.error = 'The maximum file size is 15MB.'
this.preview = {}
this.$emit('input', this.value)
} else {
this.filename = file.name
const reader = new FileReader()
reader.addEventListener('load', (event) => {
this.rawData = event.target.result
this.reload()
})
reader.readAsBinaryString(event.target.files[0])
}
},
/**
* Parses the raw data with the user configured delimiter. If all looks good the
* data is stored as a string because all the entries don't have to be reactive.
* Also a small preview will be generated. If something goes wrong, for example
* when the CSV doesn't have any entries the appropriate error will be shown.
*/
reload() {
Papa.parse(this.rawData, {
delimiter: this.columnSeparator === 'auto' ? '' : this.columnSeparator,
complete: (data) => {
if (data.data.length === 0) {
// We need at least a single entry otherwise the user has probably chosen
// a wrong file.
this.values.data = ''
this.error = 'This CSV file is empty.'
this.preview = {}
} else {
// If parsed successfully and it is not empty then the initial data can be
// prepared for creating the table. We store the data stringified because
// it doesn't need to be reactive.
this.values.data = JSON.stringify(data.data)
this.error = ''
this.preview = this.getPreview(
data.data,
this.values.firstRowHeader
)
}
this.$emit('input', this.value)
},
error(error) {
// Papa parse has resulted in an error which we need to display to the user.
// All previously loaded data will be removed.
this.values.data = ''
this.error = error.errors[0].message
this.preview = {}
this.$emit('input', this.value)
},
})
},
},
}
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="control mt-3">
<label class="control__label">Quick preview</label>
<div class="control__elements">
<div class="table-preview__container">
<div class="table-preview">
<div class="table-preview__row table-preview__head">
<div
v-for="(item, index) in preview.head"
:key="index"
class="table-preview__column"
>
{{ item }}
</div>
</div>
<div
v-for="(row, index) in preview.rows"
:key="index"
class="table-preview__row"
>
<div
v-for="(column, rowIndex) in row"
:key="rowIndex"
class="table-preview__column"
>
{{ column }}
</div>
</div>
<div v-if="preview.remaining > 0" class="table-preview__more">
{{ preview.remaining }} other rows
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
preview: {
type: Object,
required: true,
},
},
}
</script>

View file

@ -0,0 +1,113 @@
<template>
<div>
<div class="control">
<label class="control__label">
Paste the table data
</label>
<div class="control__description">
You can copy the cells from a spreadsheet and paste them below.
</div>
<div class="control__elements">
<textarea
type="text"
class="input input--large textarea--modal"
@input="changed($event.target.value)"
></textarea>
<div v-if="$v.content.$error" class="error">
This field is required.
</div>
</div>
</div>
<div class="control">
<label class="control__label">
First row is header
</label>
<div class="control__elements">
<Checkbox v-model="values.firstRowHeader" @input="reload()"
>yes</Checkbox
>
</div>
</div>
<div v-if="error !== ''" class="alert alert--error alert--has-icon">
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Something went wrong</div>
<p class="alert__content">
{{ error }}
</p>
</div>
<TableImporterPreview
v-if="error === '' && content !== '' && Object.keys(preview).length !== 0"
:preview="preview"
></TableImporterPreview>
</div>
</template>
<script>
import { required } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form'
import importer from '@baserow/modules/database/mixins/importer'
import TableImporterPreview from '@baserow/modules/database/components/table/TableImporterPreview'
import Papa from 'papaparse'
export default {
name: 'TablePasteImporter',
components: { TableImporterPreview },
mixins: [form, importer],
data() {
return {
values: {
data: '',
firstRowHeader: true,
},
content: '',
error: '',
preview: {},
}
},
validations: {
values: {
data: { required },
},
content: { required },
},
methods: {
changed(content) {
this.content = content
this.reload()
},
reload() {
if (this.content === '') {
this.values.data = ''
this.error = ''
this.preview = {}
this.$emit('input', this.value)
return
}
Papa.parse(this.content, {
delimiter: '\t',
complete: (data) => {
// If parsed successfully and it is not empty then the initial data can be
// prepared for creating the table. We store the data stringified because it
// doesn't need to be reactive.
this.values.data = JSON.stringify(data.data)
this.error = ''
this.preview = this.getPreview(data.data, this.values.firstRowHeader)
this.$emit('input', this.value)
},
error(error) {
// Papa parse has resulted in an error which we need to display to the user.
// All previously loaded data will be removed.
this.values.data = ''
this.error = error.errors[0].message
this.preview = {}
this.$emit('input', this.value)
},
})
},
},
}
</script>

View file

@ -0,0 +1,86 @@
import { Registerable } from '@baserow/modules/core/registry'
import TableCSVImporter from '@baserow/modules/database/components/table/TableCSVImporter'
import TablePasteImporter from '@baserow/modules/database/components/table/TablePasteImporter'
export class ImporterType extends Registerable {
/**
* Should return a font awesome class name related to the icon that must be displayed
* to the user.
*/
getIconClass() {
throw new Error('The icon class of an importer type must be set.')
}
/**
* Should return a human readable name that indicating what the importer does.
*/
getName() {
throw new Error('The name of an importer type must be set.')
}
/**
* Should return the component that is added to the CreateTableModal when the
* importer is chosen. It should handle all the user input and additional form
* fields and it should generate a compatible data object that must be added to
* the form values.
*/
getFormComponent() {
return null
}
constructor() {
super()
this.type = this.getType()
this.iconClass = this.getIconClass()
this.name = this.getName()
if (this.type === null) {
throw new Error('The type name of an importer type must be set.')
}
}
serialize() {
return {
type: this.type,
iconClass: this.iconClass,
name: this.name,
}
}
}
export class CSVImporterType extends ImporterType {
getType() {
return 'csv'
}
getIconClass() {
return 'file-csv'
}
getName() {
return 'Import a CSV file'
}
getFormComponent() {
return TableCSVImporter
}
}
export class PasteImporterType extends ImporterType {
getType() {
return 'paste'
}
getIconClass() {
return 'paste'
}
getName() {
return 'Paste table data'
}
getFormComponent() {
return TablePasteImporter
}
}

View file

@ -0,0 +1,43 @@
/**
* Mixin that introduces helper methods for the importer form component.
*/
export default {
methods: {
/**
* Generates an object that can used to render a quick preview of the provided
* data. Can be used in combination with the TableImporterPreview component.
*/
getPreview(data, firstRowHeader) {
let head = data[0]
let rows = data.slice(1, 4)
let remaining = data.length - rows.length - 1
const columns = Math.max(...data.map((entry) => entry.length))
/**
* Fills the row with a minimum amount of empty columns.
*/
const fill = (row, columns) => {
for (let i = row.length; i < columns; i++) {
row.push('')
}
return row
}
// If the first row is not the header, a header containing columns named
// 'Column N' needs to be generated.
if (!firstRowHeader) {
head = []
for (let i = 1; i <= columns; i++) {
head.push(`Column ${i}`)
}
rows = data.slice(0, 3)
remaining = data.length - rows.length
}
head = fill(head, columns)
rows.map((row) => fill(row, columns))
return { columns, head, rows, remaining }
},
},
}

View file

@ -23,6 +23,10 @@ import {
EmptyViewFilterType,
NotEmptyViewFilterType,
} from '@baserow/modules/database/viewFilters'
import {
CSVImporterType,
PasteImporterType,
} from '@baserow/modules/database/importerTypes'
import tableStore from '@baserow/modules/database/store/table'
import viewStore from '@baserow/modules/database/store/view'
@ -56,4 +60,6 @@ export default ({ store, app }) => {
app.$registry.register('field', new DateFieldType())
app.$registry.register('field', new URLFieldType())
app.$registry.register('field', new EmailFieldType())
app.$registry.register('importer', new CSVImporterType())
app.$registry.register('importer', new PasteImporterType())
}

View file

@ -3,7 +3,12 @@ export default (client) => {
fetchAll(databaseId) {
return client.get(`/database/tables/database/${databaseId}/`)
},
create(databaseId, values) {
create(databaseId, values, initialData = null, firstRowHeader = false) {
if (initialData !== null) {
values.data = initialData
values.first_row_header = firstRowHeader
}
return client.post(`/database/tables/database/${databaseId}/`, values)
},
get(tableId) {

View file

@ -57,7 +57,10 @@ export const actions = {
* Create a new table based on the provided values and add it to the tables
* of the provided database.
*/
async create({ commit, dispatch }, { database, values }) {
async create(
{ commit, dispatch },
{ database, values, initialData = null, firstRowHeader = true }
) {
const type = DatabaseApplicationType.getType()
// Check if the provided database (application) has the correct type.
@ -70,7 +73,9 @@ export const actions = {
const { data } = await TableService(this.$client).create(
database.id,
values
values,
initialData,
firstRowHeader
)
commit('ADD_ITEM', { database, table: data })

View file

@ -27,6 +27,7 @@
"normalize-scss": "7.0.1",
"nuxt": "2.12.1",
"nuxt-env": "^0.1.0",
"papaparse": "^5.3.0",
"sass-loader": "8.0.2",
"thenby": "^1.3.4",
"vuejs-datepicker": "^1.6.2",

View file

@ -7988,6 +7988,11 @@ pako@~1.0.5:
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
papaparse@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.3.0.tgz#ab1702feb96e79ab4309652f36db9536563ad05a"
integrity sha512-Lb7jN/4bTpiuGPrYy4tkKoUS8sTki8zacB5ke1p5zolhcSE4TlWgrlsxjrDTbG/dFVh07ck7X36hUf/b5V68pg==
parallel-transform@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"