1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-10 23:50:12 +00:00

Count field

This commit is contained in:
Bram Wiepjes 2023-06-01 17:15:42 +00:00
parent cd177703ea
commit 4751dc5dc6
25 changed files with 853 additions and 13 deletions
backend
changelog/entries/unreleased/feature
premium/backend/tests/baserow_premium_tests/export
web-frontend
locales
modules/database
test/unit/database

View file

@ -541,6 +541,7 @@ SPECTACULAR_SETTINGS = {
"multiple_select",
"phone_number",
"formula",
"count",
"lookup",
],
"ViewFilterTypesEnum": [

View file

@ -12,6 +12,7 @@ from baserow.contrib.database.export_serialized import DatabaseExportSerializedS
from baserow.contrib.database.fields.models import (
NUMBER_MAX_DECIMAL_PLACES,
BooleanField,
CountField,
CreatedOnField,
DateField,
EmailField,
@ -377,3 +378,21 @@ class PhoneAirtableColumnType(AirtableColumnType):
return value
except ValidationError:
return ""
class CountAirtableColumnType(AirtableColumnType):
type = "count"
def to_baserow_field(self, raw_airtable_table, raw_airtable_column):
type_options = raw_airtable_column.get("typeOptions", {})
return CountField(through_field_id=type_options.get("relationColumnId"))
def to_baserow_export_serialized_value(
self,
row_id_mapping,
raw_airtable_column,
baserow_field,
value,
files_to_download,
):
return None

View file

@ -98,6 +98,12 @@ ERROR_FIELD_CIRCULAR_REFERENCE = (
HTTP_400_BAD_REQUEST,
"Fields cannot reference each other resulting in a circular chain of references.",
)
ERROR_INVALID_COUNT_THROUGH_FIELD = (
"ERROR_INVALID_COUNT_THROUGH_FIELD",
HTTP_400_BAD_REQUEST,
"The provided through field does not exist, is in a different table or is not a "
"link row field.",
)
ERROR_INVALID_LOOKUP_THROUGH_FIELD = (
"ERROR_INVALID_LOOKUP_THROUGH_FIELD",
HTTP_400_BAD_REQUEST,

View file

@ -187,6 +187,7 @@ class DatabaseConfig(AppConfig):
from .fields.field_types import (
BooleanFieldType,
CountFieldType,
CreatedOnFieldType,
DateFieldType,
EmailFieldType,
@ -222,6 +223,7 @@ class DatabaseConfig(AppConfig):
field_type_registry.register(MultipleSelectFieldType())
field_type_registry.register(PhoneNumberFieldType())
field_type_registry.register(FormulaFieldType())
field_type_registry.register(CountFieldType())
field_type_registry.register(LookupFieldType())
field_type_registry.register(MultipleCollaboratorsFieldType())
@ -436,6 +438,7 @@ class DatabaseConfig(AppConfig):
from .airtable.airtable_column_types import (
CheckboxAirtableColumnType,
CountAirtableColumnType,
DateAirtableColumnType,
ForeignKeyAirtableColumnType,
FormulaAirtableColumnType,
@ -463,6 +466,7 @@ class DatabaseConfig(AppConfig):
airtable_column_type_registry.register(MultilineTextAirtableColumnType())
airtable_column_type_registry.register(MultipleAttachmentAirtableColumnType())
airtable_column_type_registry.register(RichTextTextAirtableColumnType())
airtable_column_type_registry.register(CountAirtableColumnType())
from baserow.contrib.database.table.usage_types import (
TableWorkspaceStorageUsageItemType,

View file

@ -196,6 +196,13 @@ class AllProvidedCollaboratorIdsMustBeValidUsers(ValidationError):
)
class InvalidCountThroughField(Exception):
"""
Raised when a count field is attempted to be created or updated with a through
field that does not exist, is in a different table or is not a link row field.
"""
class InvalidLookupThroughField(Exception):
"""
Raised when a a lookup field is attempted to be created or updated with a through

View file

@ -183,6 +183,12 @@ def construct_all_possible_field_kwargs(
},
{"name": "formula_link_url_only", "formula": "link('https://google.com')"},
],
"count": [
{
"name": "count",
"through_field_name": "link_row",
}
],
"lookup": [
{
"name": "lookup",

View file

@ -30,6 +30,7 @@ from rest_framework import serializers
from baserow.contrib.database.api.fields.errors import (
ERROR_DATE_FORCE_TIMEZONE_OFFSET_ERROR,
ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE,
ERROR_INVALID_COUNT_THROUGH_FIELD,
ERROR_INVALID_LOOKUP_TARGET_FIELD,
ERROR_INVALID_LOOKUP_THROUGH_FIELD,
ERROR_LINK_ROW_TABLE_NOT_IN_SAME_DATABASE,
@ -87,6 +88,7 @@ from .exceptions import (
DateForceTimezoneOffsetValueError,
FieldDoesNotExist,
IncompatiblePrimaryFieldTypeError,
InvalidCountThroughField,
InvalidLookupTargetField,
InvalidLookupThroughField,
LinkRowTableNotInSameDatabase,
@ -110,6 +112,7 @@ from .handler import FieldHandler
from .models import (
AbstractSelectOption,
BooleanField,
CountField,
CreatedOnField,
DateField,
EmailField,
@ -3547,6 +3550,109 @@ class FormulaFieldType(ReadOnlyFieldType):
return self.to_baserow_formula_type(field.specific).can_represent_date
class CountFieldType(FormulaFieldType):
type = "count"
model_class = CountField
api_exceptions_map = {
**FormulaFieldType.api_exceptions_map,
InvalidCountThroughField: ERROR_INVALID_COUNT_THROUGH_FIELD,
}
can_get_unique_values = False
allowed_fields = BASEROW_FORMULA_TYPE_ALLOWED_FIELDS + [
"through_field_id",
]
serializer_field_names = BASEROW_FORMULA_TYPE_ALLOWED_FIELDS + [
"through_field_id",
"formula_type",
]
serializer_field_overrides = {
"through_field_id": serializers.IntegerField(
required=False,
allow_null=True,
source="through_field.id",
help_text="The id of the link row field to lookup values for. Will override"
" the `through_field_name` parameter if both are provided, however only "
"one is required.",
),
"nullable": serializers.BooleanField(required=False, read_only=True),
}
def before_create(
self, table, primary, allowed_field_values, order, user, field_kwargs
):
self._validate_through_field_values(
table,
allowed_field_values,
field_kwargs,
)
def get_fields_needing_periodic_update(self) -> Optional[QuerySet]:
return None
def before_update(self, from_field, to_field_values, user, kwargs):
if isinstance(from_field, CountField):
through_field_id = (
from_field.through_field.id
if from_field.through_field is not None
else None
)
self._validate_through_field_values(
from_field.table,
to_field_values,
kwargs,
through_field_id,
)
else:
self._validate_through_field_values(
from_field.table, to_field_values, kwargs
)
def _validate_through_field_values(
self,
table,
values,
all_kwargs,
default_through_field_id=None,
):
through_field_id = values.get("through_field_id", default_through_field_id)
through_field_name = all_kwargs.get("through_field_name", None)
# If the `through_field_name` is provided in the kwargs when creating or
# updating a field, then we want to find the `link_row` field by its name.
if through_field_name is not None:
try:
through_field_id = table.field_set.get(name=through_field_name).id
except Field.DoesNotExist:
raise InvalidCountThroughField()
try:
through_field = FieldHandler().get_field(through_field_id, LinkRowField)
except FieldDoesNotExist:
# Occurs when the through_field_id points at a non LinkRowField
raise InvalidCountThroughField()
if through_field.table != table:
raise InvalidCountThroughField()
values["through_field_id"] = through_field.id
# There is never a need to allow decimal places on the count field.
# Therefore, we reset it to 0 to make sure when a formula converts to count,
# it will have the right value.
values["number_decimal_places"] = 0
def import_serialized(
self,
table: "Table",
serialized_values: Dict[str, Any],
id_mapping: Dict[str, Any],
) -> "Field":
serialized_copy = serialized_values.copy()
serialized_copy["through_field_id"] = id_mapping["database_fields"][
serialized_values["through_field_id"]
]
return super().import_serialized(table, serialized_copy, id_mapping)
class LookupFieldType(FormulaFieldType):
type = "lookup"
model_class = LookupField

View file

@ -521,6 +521,35 @@ class FormulaField(Field):
)
class CountField(FormulaField):
through_field = models.ForeignKey(
Field,
on_delete=models.SET_NULL,
related_name="count_fields_used_by",
null=True,
blank=True,
)
def save(self, *args, **kwargs):
from baserow.contrib.database.formula.ast.function_defs import BaserowCount
from baserow.contrib.database.formula.ast.tree import BaserowFieldReference
field_reference = BaserowFieldReference(
getattr(self.through_field, "name", ""), None, None
)
self.formula = f"{BaserowCount.type}({field_reference})"
super().save(*args, **kwargs)
def __str__(self):
return (
"CountField(\n"
+ f"formula={self.formula},\n"
+ f"through_field_id={self.through_field_id},\n"
+ f"error={self.error},\n"
+ ")"
)
class LookupField(FormulaField):
through_field = models.ForeignKey(
Field,

View file

@ -56,9 +56,11 @@ def fill_table_fields(limit, table):
field_handler = FieldHandler()
all_kwargs_per_type = construct_all_possible_field_kwargs(None, None, None, None)
first_user = table.database.workspace.users.first()
# Keep all fields but link_row and lookup
# Keep all fields but link_row, count and lookup
allowed_field_list = [
f for f in all_kwargs_per_type.items() if f[0] not in ["link_row", "lookup"]
f
for f in all_kwargs_per_type.items()
if f[0] not in ["link_row", "count", "lookup"]
]
for _ in range(limit):
# This is a helper cli command, randomness is not being used for any security
@ -85,7 +87,7 @@ def create_field_for_every_type(table):
first_user = table.database.workspace.users.first()
i = 0
for field_type_name, all_possible_kwargs in all_kwargs_per_type.items():
if field_type_name in ["link_row", "lookup"]:
if field_type_name in ["link_row", "count", "lookup"]:
continue
for kwargs in all_possible_kwargs:
kwargs.pop("primary", None)

View file

@ -0,0 +1,43 @@
# Generated by Django 3.2.18 on 2023-05-25 12:23
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("database", "0114_alter_airtableimportjob_airtable_share_id"),
]
operations = [
migrations.CreateModel(
name="CountField",
fields=[
(
"formulafield_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="database.formulafield",
),
),
(
"through_field",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="count_fields_used_by",
to="database.field",
),
),
],
options={
"abstract": False,
},
bases=("database.formulafield",),
),
]

View file

@ -225,7 +225,9 @@ def setup_interesting_test_table(
sha256_hash="name",
)
missing_fields = set(name_to_field_id.keys()) - set(values.keys()) - {"lookup"}
missing_fields = (
set(name_to_field_id.keys()) - set(values.keys()) - {"lookup", "count"}
)
assert missing_fields == set(), (
"Please update the dictionary above with interesting test values for your new "
f"field type. In the values dict you are missing the fields {missing_fields}."

View file

@ -3,6 +3,7 @@ import responses
from baserow.contrib.database.airtable.airtable_column_types import (
CheckboxAirtableColumnType,
CountAirtableColumnType,
DateAirtableColumnType,
ForeignKeyAirtableColumnType,
FormulaAirtableColumnType,
@ -19,6 +20,7 @@ from baserow.contrib.database.airtable.airtable_column_types import (
from baserow.contrib.database.airtable.registry import airtable_column_type_registry
from baserow.contrib.database.fields.models import (
BooleanField,
CountField,
CreatedOnField,
DateField,
EmailField,
@ -1090,3 +1092,35 @@ def test_airtable_import_url_column(data_fixture, api_client):
)
== "https://test.nl"
)
@pytest.mark.django_db
@responses.activate
def test_airtable_import_count_column(data_fixture, api_client):
airtable_field = {
"id": "fldG9y88Zw7q7u4Z7i4",
"name": "Count",
"type": "count",
"typeOptions": {
"relationColumnId": "fldABC88Zw7q7u4Z7i4",
"dependencies": [],
"resultType": "number",
"resultIsArray": False,
},
}
(
baserow_field,
airtable_column_type,
) = airtable_column_type_registry.from_airtable_column_to_serialized(
{}, airtable_field
)
assert isinstance(baserow_field, CountField)
assert isinstance(airtable_column_type, CountAirtableColumnType)
assert baserow_field.through_field_id == "fldABC88Zw7q7u4Z7i4"
assert (
airtable_column_type.to_baserow_export_serialized_value(
{}, airtable_field, baserow_field, "1", {}
)
is None
)

View file

@ -207,7 +207,7 @@ def test_to_baserow_database_export():
assert baserow_database_export["tables"][1]["id"] == "tbl7glLIGtH8C8zGCzb"
assert baserow_database_export["tables"][1]["name"] == "Data"
assert baserow_database_export["tables"][1]["order"] == 1
assert len(baserow_database_export["tables"][1]["fields"]) == 24
assert len(baserow_database_export["tables"][1]["fields"]) == 25
# We don't have to check all the fields and rows, just a single one, because we have
# separate tests for mapping the Airtable fields and values to Baserow.

View file

@ -334,6 +334,7 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
"value": "A",
},
"formula_text": "test FORMULA",
"count": "3",
"lookup": [
{"id": 1, "value": "linked_row_1"},
{"id": 2, "value": "linked_row_2"},

View file

@ -230,11 +230,11 @@ def test_can_export_every_interesting_different_field_to_csv(
"file_link_row,file,single_select,multiple_select,multiple_collaborators,"
"phone_number,formula_text,formula_int,formula_bool,formula_decimal,formula_dateinterval,"
"formula_date,formula_singleselect,formula_email,formula_link_with_label,"
"formula_link_url_only,lookup\r\n"
"formula_link_url_only,count,lookup\r\n"
"1,,,,,,,,,0,False,,,,,,,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
"02/01/2021 13:00,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
"02/01/2021 13:00,,,,,,,,,,,test FORMULA,1,True,33.3333333333,1 day,"
"2020-01-01,,,label (https://google.com),https://google.com,\r\n"
"2020-01-01,,,label (https://google.com),https://google.com,0,\r\n"
"2,text,long_text,https://www.google.com,test@example.com,-1,1,-1.2,1.2,3,True,"
"02/01/2020 01:23,02/01/2020,01/02/2020 01:23,01/02/2020,01/02/2020 02:23,"
"01/02/2020 02:23,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
@ -246,7 +246,7 @@ def test_can_export_every_interesting_different_field_to_csv(
'b.txt (http://localhost:8000/media/user_files/other_name.txt)",A,"D,C,E",'
'"user2@example.com,user3@example.com",+4412345678,test FORMULA,1,True,33.3333333333,'
"1 day,2020-01-01,A,test@example.com,label (https://google.com),https://google.com,"
'"linked_row_1,linked_row_2,"\r\n'
'3,"linked_row_1,linked_row_2,"\r\n'
)
assert contents == expected

View file

@ -0,0 +1,427 @@
from io import BytesIO
from django.urls import reverse
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST
from baserow.contrib.database.fields.exceptions import InvalidCountThroughField
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.formula import BaserowFormulaNumberType
from baserow.contrib.database.rows.handler import RowHandler
from baserow.core.handler import CoreHandler
@pytest.mark.django_db
def test_create_count_through_field_with_invalid_linkrowfield(
data_fixture, api_client, django_assert_num_queries
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
table2 = data_fixture.create_database_table(user=user, database=table.database)
data_fixture.create_text_field(name="primaryfield", table=table, primary=True)
data_fixture.create_text_field(name="primaryfield", table=table2, primary=True)
linkrowfield = FieldHandler().create_field(
user,
table2,
"link_row",
name="linkrowfield",
link_row_table=table2,
)
with pytest.raises(InvalidCountThroughField):
FieldHandler().create_field(
user,
table,
"count",
name="count_field",
through_field_id=linkrowfield.id,
)
@pytest.mark.django_db
def test_create_count_through_field_with_invalid_linkrowfield_via_api(
data_fixture, api_client, django_assert_num_queries
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
table2 = data_fixture.create_database_table(user=user, database=table.database)
data_fixture.create_text_field(name="primaryfield", table=table, primary=True)
data_fixture.create_text_field(name="primaryfield", table=table2, primary=True)
linkrowfield = FieldHandler().create_field(
user,
table2,
"link_row",
name="linkrowfield",
link_row_table=table2,
)
response = api_client.post(
reverse("api:database:fields:list", kwargs={"table_id": table.id}),
{"name": "Test 1", "type": "count", "link_row_table_id": linkrowfield.id},
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_COUNT_THROUGH_FIELD"
@pytest.mark.django_db
def test_create_count_through_field_name(
data_fixture, api_client, django_assert_num_queries
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
table2 = data_fixture.create_database_table(user=user, database=table.database)
data_fixture.create_text_field(name="primaryfield", table=table, primary=True)
data_fixture.create_text_field(name="primaryfield", table=table2, primary=True)
linkrowfield = FieldHandler().create_field(
user,
table,
"link_row",
name="linkrowfield",
link_row_table=table2,
)
field = FieldHandler().create_field(
user,
table,
"count",
name="count_field",
through_field_name=linkrowfield.name,
)
assert field.through_field_id == linkrowfield.id
@pytest.mark.django_db
def test_update_count_through_field_name(
data_fixture, api_client, django_assert_num_queries
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
table2 = data_fixture.create_database_table(user=user, database=table.database)
data_fixture.create_text_field(name="primaryfield", table=table, primary=True)
data_fixture.create_text_field(name="primaryfield", table=table2, primary=True)
linkrowfield_1 = FieldHandler().create_field(
user,
table,
"link_row",
name="linkrowfield_1",
link_row_table=table2,
)
linkrowfield_2 = FieldHandler().create_field(
user,
table,
"link_row",
name="linkrowfield_2",
link_row_table=table2,
)
field = FieldHandler().create_field(
user,
table,
"count",
name="count_field",
through_field_name=linkrowfield_1.name,
)
field = FieldHandler().update_field(
user,
field,
through_field_name=linkrowfield_2.name,
)
assert field.through_field_id == linkrowfield_2.id
@pytest.mark.django_db
def test_can_update_count_field_value(
data_fixture, api_client, django_assert_num_queries
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
table2 = data_fixture.create_database_table(user=user, database=table.database)
table_primary_field = data_fixture.create_text_field(
name="p", table=table, primary=True
)
data_fixture.create_text_field(name="primaryfield", table=table2, primary=True)
linkrowfield = FieldHandler().create_field(
user,
table,
"link_row",
name="linkrowfield",
link_row_table=table2,
)
table2_model = table2.get_model(attribute_names=True)
a = table2_model.objects.create(primaryfield="primary a")
b = table2_model.objects.create(primaryfield="primary b")
table_model = table.get_model(attribute_names=True)
table_row = table_model.objects.create()
table_row.linkrowfield.add(a.id)
table_row.linkrowfield.add(b.id)
table_row.save()
count_field = FieldHandler().create_field(
user,
table,
"count",
name="count_field",
through_field_id=linkrowfield.id,
)
response = api_client.get(
reverse("api:database:rows:list", kwargs={"table_id": table.id}),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.json() == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
f"field_{table_primary_field.id}": None,
f"field_{linkrowfield.id}": [
{"id": a.id, "value": "primary a"},
{"id": b.id, "value": "primary b"},
],
f"field_{count_field.id}": "2",
"id": table_row.id,
"order": "1.00000000000000000000",
}
],
}
assert response.status_code == HTTP_200_OK
@pytest.mark.django_db
def test_can_batch_create_count_field_value(
data_fixture, api_client, django_assert_num_queries
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
table2 = data_fixture.create_database_table(user=user, database=table.database)
table_primary_field = data_fixture.create_text_field(
name="tableprimary", table=table, primary=True
)
data_fixture.create_text_field(name="table2primary", table=table2, primary=True)
link_row_field = FieldHandler().create_field(
user,
table,
"link_row",
name="linkrowfield",
link_row_table=table2,
)
count_field = FieldHandler().create_field(
user,
table,
"count",
name="count_field",
through_field_id=link_row_field.id,
)
table2_model = table2.get_model(attribute_names=True)
table2_row_1 = table2_model.objects.create(table2primary="row A")
response = api_client.post(
reverse(
"api:database:rows:batch",
kwargs={"table_id": table.id},
),
{
"items": [
{
f"field_{table_primary_field.id}": "row 1",
f"field_{link_row_field.id}": [table2_row_1.id],
}
]
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"items": [
{
"id": 1,
"order": "1.00000000000000000000",
f"field_{table_primary_field.id}": "row 1",
f"field_{link_row_field.id}": [{"id": 1, "value": "row A"}],
f"field_{count_field.id}": "1",
}
]
}
@pytest.mark.django_db
def test_can_batch_update_count_field_value(
data_fixture, api_client, django_assert_num_queries
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
table2 = data_fixture.create_database_table(user=user, database=table.database)
table_primary_field = data_fixture.create_text_field(
name="tableprimary", table=table, primary=True
)
data_fixture.create_text_field(name="table2primary", table=table2, primary=True)
link_row_field = FieldHandler().create_field(
user,
table,
"link_row",
name="linkrowfield",
link_row_table=table2,
)
count_field = FieldHandler().create_field(
user,
table,
"count",
name="count_field",
through_field_id=link_row_field.id,
)
table1_model = table.get_model(attribute_names=True)
table1_row_1 = table1_model.objects.create(tableprimary="row 1")
table2_model = table2.get_model(attribute_names=True)
table2_row_1 = table2_model.objects.create(table2primary="row A")
response = api_client.patch(
reverse(
"api:database:rows:batch",
kwargs={"table_id": table.id},
),
{
"items": [
{"id": table1_row_1.id, f"field_{link_row_field.id}": [table2_row_1.id]}
]
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"items": [
{
"id": 1,
"order": "1.00000000000000000000",
f"field_{table_primary_field.id}": "row 1",
f"field_{link_row_field.id}": [{"id": 1, "value": "row A"}],
f"field_{count_field.id}": "1",
}
]
}
@pytest.mark.django_db(transaction=True)
def test_import_export_tables_with_count_fields(
data_fixture, django_assert_num_queries
):
user = data_fixture.create_user()
imported_workspace = data_fixture.create_workspace(user=user)
database = data_fixture.create_database_application(user=user, name="Placeholder")
table = data_fixture.create_database_table(
name="Example", database=database, order=0
)
customers_table = data_fixture.create_database_table(
name="Customers", database=database, order=1
)
customer_name = data_fixture.create_text_field(table=customers_table, primary=True)
customer_age = data_fixture.create_number_field(table=customers_table)
field_handler = FieldHandler()
core_handler = CoreHandler()
link_row_field = field_handler.create_field(
user=user,
table=table,
name="Link Row",
type_name="link_row",
link_row_table=customers_table,
)
row_handler = RowHandler()
c_row = row_handler.create_row(
user=user,
table=customers_table,
values={
f"field_{customer_name.id}": "mary",
f"field_{customer_age.id}": 65,
},
)
c_row_2 = row_handler.create_row(
user=user,
table=customers_table,
values={
f"field_{customer_name.id}": "bob",
f"field_{customer_age.id}": 67,
},
)
row = row_handler.create_row(
user=user,
table=table,
values={f"field_{link_row_field.id}": [c_row.id, c_row_2.id]},
)
count_field = field_handler.create_field(
user=user,
table=table,
name="count",
type_name="count",
through_field_id=link_row_field.id,
)
exported_applications = core_handler.export_workspace_applications(
database.workspace, BytesIO()
)
imported_applications, id_mapping = core_handler.import_applications_to_workspace(
imported_workspace, exported_applications, BytesIO(), None
)
imported_database = imported_applications[0]
imported_tables = imported_database.table_set.all()
imported_table = imported_tables[0]
assert imported_table.name == table.name
imported_count_field = imported_table.field_set.get(name=count_field.name).specific
imported_through_field = imported_table.field_set.get(
name=link_row_field.name
).specific
assert imported_count_field.formula == count_field.formula
assert imported_count_field.formula_type == BaserowFormulaNumberType.type
assert imported_count_field.through_field.name == link_row_field.name
assert imported_count_field.through_field_id == imported_through_field.id
imported_table_model = imported_table.get_model(attribute_names=True)
imported_rows = imported_table_model.objects.all()
assert imported_rows.count() == 1
imported_row = imported_rows.first()
assert imported_row.id == row.id
assert imported_row.count == 2
@pytest.mark.django_db
def test_convert_count_to_text_field_via_api(data_fixture, api_client):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
table2 = data_fixture.create_database_table(user=user, database=table.database)
data_fixture.create_text_field(name="tableprimary", table=table, primary=True)
data_fixture.create_text_field(name="tableprimary", table=table2, primary=True)
link_row_field = FieldHandler().create_field(
user,
table,
"link_row",
name="linkrowfield",
link_row_table=table2,
)
count_field = FieldHandler().create_field(
user,
table,
"count",
name="count_field",
through_field_id=link_row_field.id,
)
# text_field = FieldHandler().update_field(user, count_field, 'text')
response = api_client.patch(
reverse("api:database:fields:item", kwargs={"field_id": count_field.id}),
{"name": "Test 1", "type": "text"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK

View file

@ -183,7 +183,7 @@ def test_view_unique_count_aggregation_for_interesting_table(data_fixture):
user, grid_view, aggregation_query, model=model, with_total=True
)
assert len(result.keys()) == 33
assert len(result.keys()) == 34
for field_obj in model._field_objects.values():
field = field_obj["field"]
@ -198,6 +198,7 @@ def test_view_unique_count_aggregation_for_interesting_table(data_fixture):
"email",
"rating",
"phone_number",
"count",
]
or field_type == "formula"
and field.formula_type == "char"

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Introduced count field",
"issue_number": 224,
"bullet_points": [],
"created_at": "2023-05-25"
}

View file

@ -89,6 +89,7 @@ def test_can_export_every_interesting_different_field_to_json(
"formula_link_url_only": {
"url": "https://google.com"
},
"count": 0,
"lookup": []
},
{
@ -181,6 +182,7 @@ def test_can_export_every_interesting_different_field_to_json(
"formula_link_url_only": {
"url": "https://google.com"
},
"count": 3,
"lookup": [
"linked_row_1",
"linked_row_2",
@ -318,6 +320,7 @@ def test_can_export_every_interesting_different_field_to_xml(
<formula-link-url-only>
<url>https://google.com</url>
</formula-link-url-only>
<count>0</count>
<lookup/>
</row>
<row>
@ -410,6 +413,7 @@ def test_can_export_every_interesting_different_field_to_xml(
<formula-link-url-only>
<url>https://google.com</url>
</formula-link-url-only>
<count>3</count>
<lookup>
<item>linked_row_1</item>
<item>linked_row_2</item>

View file

@ -92,6 +92,7 @@
"multipleSelect": "Multiple select",
"phoneNumber": "Phone number",
"formula": "Formula",
"count": "Count",
"lookup": "Lookup",
"multipleCollaborators": "Collaborators"
},
@ -127,7 +128,8 @@
"multipleSelect": "Accepts an array of mixed integers or text values each representing the chosen select option id or value. In case of a text value, the first matching option is selected.",
"phoneNumber": "Accepts a phone number which has a maximum length of 100 characters consisting solely of digits, spaces and the following characters: Nx,._+*()#=;/- .",
"formula": "A read-only field defined by a formula written in the Baserow formula language.",
"lookup": "A read-only field connected to a link row field which returns an array of values and row ids from the chosen lookup field in the linked table.",
"count": "A read-only field connected to a link to table field which returns the number of relations.",
"lookup": "A read-only field connected to a link to table field which returns an array of values and row ids from the chosen lookup field in the linked table.",
"multipleCollaborators": "Accepts an array of objects where each object contains a user's id."
},
"viewFilter": {
@ -334,7 +336,7 @@
"datetimeFormatTzDescription": "Converts the date to text given a way of formatting the date in the specified timezone.",
"toNumberDescription": "Converts the input to a number if possible.",
"fieldDescription": "Returns the field named by the single text argument.",
"lookupDescription": "Looks up the values from a field in another table for rows in a link row field. The first argument should be the name of a link row field in the current table and the second should be the name of a field in the linked table.",
"lookupDescription": "Looks up the values from a field in another table for rows in a link to table field. The first argument should be the name of a link to table field in the current table and the second should be the name of a field in the linked table.",
"isBlankDescription": "Returns true if the argument is null, empty or blank, false otherwise.",
"isNullDescription": "Returns true if the argument is null, false otherwise.",
"tDescription": "Returns the arguments value if it is text, but otherwise ''.",

View file

@ -0,0 +1,97 @@
<template>
<div>
<div class="control">
<Alert v-if="linkRowFieldsInThisTable.length === 0" minimal type="error">
{{ $t('fieldCountSubForm.noTable') }}
</Alert>
<div v-if="linkRowFieldsInThisTable.length > 0">
<label class="control__label control__label--small">
{{ $t('fieldCountSubForm.selectThroughFieldLabel') }}
</label>
<div class="control__elements">
<div class="control">
<Dropdown
v-model="values.through_field_id"
:class="{ 'dropdown--error': $v.values.through_field_id.$error }"
@hide="$v.values.through_field_id.$touch()"
>
<DropdownItem
v-for="field in linkRowFieldsInThisTable"
:key="field.id"
:disabled="field.disabled"
:name="field.name"
:value="field.id"
:icon="field.icon"
></DropdownItem>
</Dropdown>
<div v-if="$v.values.through_field_id.$error" class="error">
{{ $t('error.requiredField') }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { required } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form'
import fieldSubForm from '@baserow/modules/database/mixins/fieldSubForm'
import { DatabaseApplicationType } from '@baserow/modules/database/applicationTypes'
export default {
name: 'FieldCountSubForm',
mixins: [form, fieldSubForm],
data() {
return {
allowedValues: ['through_field_id'],
values: {
through_field_id: null,
},
}
},
computed: {
linkRowFieldsInThisTable() {
const fields = this.$store.getters['field/getAll']
return fields
.filter((f) => f.type === 'link_row')
.map((f) => {
const fieldType = this.$registry.get('field', f.type)
f.icon = fieldType.getIconClass()
f.disabled = !this.tableIdsAccessible.includes(f.link_row_table_id)
return f
})
},
allTables() {
const databaseType = DatabaseApplicationType.getType()
return this.$store.getters['application/getAll'].reduce(
(tables, application) => {
if (application.type === databaseType) {
return tables.concat(application.tables || [])
}
return tables
},
[]
)
},
tableIdsAccessible() {
return this.allTables.map((table) => table.id)
},
},
validations: {
values: {
through_field_id: { required },
},
},
methods: {
isValid() {
return (
form.methods.isValid().call(this) &&
this.linkRowFieldsInThisTable.length > 0
)
},
},
}
</script>

View file

@ -95,6 +95,7 @@ import {
import GridViewFieldFormula from '@baserow/modules/database/components/view/grid/fields/GridViewFieldFormula'
import FieldFormulaSubForm from '@baserow/modules/database/components/field/FieldFormulaSubForm'
import FieldLookupSubForm from '@baserow/modules/database/components/field/FieldLookupSubForm'
import FieldCountSubForm from '@baserow/modules/database/components/field/FieldCountSubForm'
import RowEditFieldFormula from '@baserow/modules/database/components/row/RowEditFieldFormula'
import ViewService from '@baserow/modules/database/services/view'
import FormService from '@baserow/modules/database/services/view/form'
@ -2720,6 +2721,33 @@ export class FormulaFieldType extends FieldType {
}
}
export class CountFieldType extends FormulaFieldType {
static getType() {
return 'count'
}
getIconClass() {
return 'calculator'
}
getName() {
const { i18n } = this.app
return i18n.t('fieldType.count')
}
getDocsDescription(field) {
return this.app.i18n.t('fieldDocs.count')
}
getFormComponent() {
return FieldCountSubForm
}
shouldFetchFieldSelectOptions() {
return false
}
}
export class LookupFieldType extends FormulaFieldType {
static getType() {
return 'lookup'

View file

@ -271,9 +271,13 @@
"nameNotAllowed": "This field name is not allowed.",
"nameTooLong": "This field name is too long."
},
"fieldCountSubForm": {
"noTable": "You need at least one link to table field to create a count field.",
"selectThroughFieldLabel": "Select a link to table field"
},
"fieldLookupSubForm": {
"noTable": "You need at least one link row field to create a lookup field.",
"selectThroughFieldLabel": "Select a link row field",
"noTable": "You need at least one link to table field to create a lookup field.",
"selectThroughFieldLabel": "Select a link to table field",
"selectTargetFieldLabel": "Select a field to lookup"
},
"fieldFormulaNumberSubForm": {

View file

@ -22,6 +22,7 @@ import {
PhoneNumberFieldType,
CreatedOnFieldType,
FormulaFieldType,
CountFieldType,
LookupFieldType,
MultipleCollaboratorsFieldType,
} from '@baserow/modules/database/fieldTypes'
@ -395,6 +396,7 @@ export default (context) => {
app.$registry.register('field', new MultipleSelectFieldType(context))
app.$registry.register('field', new PhoneNumberFieldType(context))
app.$registry.register('field', new FormulaFieldType(context))
app.$registry.register('field', new CountFieldType(context))
app.$registry.register('field', new LookupFieldType(context))
app.$registry.register('field', new MultipleCollaboratorsFieldType(context))

View file

@ -174,6 +174,14 @@ const mockedFields = {
table_id: 42,
type: 'multiple_collaborators',
},
count: {
id: 18,
name: 'count',
order: 18,
primary: false,
table_id: 42,
type: 'count',
},
}
const valuesToCall = [null, undefined]