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:
parent
f74f019f79
commit
3719f17042
27 changed files with 1141 additions and 30 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database
web-frontend
.stylelintrcpackage.jsonyarn.lock
modules
core/assets/scss
components
variables.scssdatabase
|
@ -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.'
|
||||
)
|
||||
|
|
|
@ -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',)
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -12,7 +12,8 @@
|
|||
"include",
|
||||
"mixin",
|
||||
"return",
|
||||
"extend"
|
||||
"extend",
|
||||
"for"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
@ -36,3 +36,7 @@
|
|||
@import 'select_row_modal';
|
||||
@import 'filters';
|
||||
@import 'sortings';
|
||||
@import 'choice_items';
|
||||
@import 'grid';
|
||||
@import 'table_preview';
|
||||
@import 'file_upload';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
.file-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-upload__button {
|
||||
margin-right: 20px;
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
27
web-frontend/modules/core/assets/scss/components/grid.scss
Normal file
27
web-frontend/modules/core/assets/scss/components/grid.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -3,6 +3,9 @@ $logo-font-stack: 'Montserrat', sans-serif;
|
|||
|
||||
$scrollbar-size: 6px;
|
||||
|
||||
$grid-columns: 12;
|
||||
$grid-width: 20px;
|
||||
|
||||
$white: #fff;
|
||||
$black: #000;
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
86
web-frontend/modules/database/importerTypes.js
Normal file
86
web-frontend/modules/database/importerTypes.js
Normal 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
|
||||
}
|
||||
}
|
43
web-frontend/modules/database/mixins/importer.js
Normal file
43
web-frontend/modules/database/mixins/importer.js
Normal 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 }
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 })
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue