mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-06 22:08:52 +00:00
Resolve "Collaborator Field Support in Form Views"
This commit is contained in:
parent
591914fcc1
commit
2c7e0013d9
28 changed files with 282 additions and 58 deletions
backend
src/baserow
contrib/database
api
fields
formula/types
core
tests/baserow
contrib/database/api
core
changelog/entries/unreleased/feature
web-frontend
locales
modules
core
database
|
@ -89,7 +89,6 @@ from baserow.contrib.database.fields.exceptions import (
|
||||||
)
|
)
|
||||||
from baserow.contrib.database.fields.handler import FieldHandler
|
from baserow.contrib.database.fields.handler import FieldHandler
|
||||||
from baserow.contrib.database.fields.job_types import DuplicateFieldJobType
|
from baserow.contrib.database.fields.job_types import DuplicateFieldJobType
|
||||||
from baserow.contrib.database.fields.models import Field
|
|
||||||
from baserow.contrib.database.fields.operations import (
|
from baserow.contrib.database.fields.operations import (
|
||||||
CreateFieldOperationType,
|
CreateFieldOperationType,
|
||||||
ListFieldsOperationType,
|
ListFieldsOperationType,
|
||||||
|
@ -191,10 +190,9 @@ class FieldsView(APIView):
|
||||||
request, ["read", "create", "update"], table, False
|
request, ["read", "create", "update"], table, False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
base_field_queryset = FieldHandler().get_base_fields_queryset()
|
||||||
fields = specific_iterator(
|
fields = specific_iterator(
|
||||||
Field.objects.filter(table=table)
|
base_field_queryset.filter(table=table),
|
||||||
.select_related("content_type")
|
|
||||||
.prefetch_related("select_options"),
|
|
||||||
per_content_type_queryset_hook=(
|
per_content_type_queryset_hook=(
|
||||||
lambda field, queryset: field_type_registry.get_by_model(
|
lambda field, queryset: field_type_registry.get_by_model(
|
||||||
field
|
field
|
||||||
|
|
|
@ -17,7 +17,10 @@ class TypeFormulaRequestSerializer(serializers.ModelSerializer):
|
||||||
class TypeFormulaResultSerializer(serializers.ModelSerializer):
|
class TypeFormulaResultSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FormulaField
|
model = FormulaField
|
||||||
fields = FormulaFieldType.serializer_field_names
|
fields = list(
|
||||||
|
set(FormulaFieldType.serializer_field_names)
|
||||||
|
- {"available_collaborators", "select_options"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BaserowFormulaSelectOptionsSerializer(serializers.ListField):
|
class BaserowFormulaSelectOptionsSerializer(serializers.ListField):
|
||||||
|
@ -36,3 +39,21 @@ class BaserowFormulaSelectOptionsSerializer(serializers.ListField):
|
||||||
return [self.child.to_representation(item) for item in select_options]
|
return [self.child.to_representation(item) for item in select_options]
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class BaserowFormulaCollaboratorsSerializer(serializers.ListField):
|
||||||
|
def get_attribute(self, instance):
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def to_representation(self, field):
|
||||||
|
field_type = field_type_registry.get_by_model(field)
|
||||||
|
|
||||||
|
# Available collaborators are needed for view filters in the frontend,
|
||||||
|
# but let's avoid the potentially slow query if not required.
|
||||||
|
if field_type.can_represent_collaborators(field):
|
||||||
|
available_collaborators = field.table.database.workspace.users.all()
|
||||||
|
return [
|
||||||
|
self.child.to_representation(item) for item in available_collaborators
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
|
@ -2394,14 +2394,11 @@ class LinkRowFieldType(
|
||||||
def enhance_field_queryset(
|
def enhance_field_queryset(
|
||||||
self, queryset: QuerySet[Field], field: Field
|
self, queryset: QuerySet[Field], field: Field
|
||||||
) -> QuerySet[Field]:
|
) -> QuerySet[Field]:
|
||||||
|
base_field_queryset = FieldHandler().get_base_fields_queryset()
|
||||||
return queryset.prefetch_related(
|
return queryset.prefetch_related(
|
||||||
models.Prefetch(
|
models.Prefetch(
|
||||||
"link_row_table__field_set",
|
"link_row_table__field_set",
|
||||||
queryset=specific_queryset(
|
queryset=specific_queryset(base_field_queryset.filter(primary=True)),
|
||||||
Field.objects.filter(primary=True)
|
|
||||||
.select_related("content_type")
|
|
||||||
.prefetch_related("select_options")
|
|
||||||
),
|
|
||||||
to_attr=LinkRowField.RELATED_PPRIMARY_FIELD_ATTR,
|
to_attr=LinkRowField.RELATED_PPRIMARY_FIELD_ATTR,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -5317,6 +5314,9 @@ class FormulaFieldType(FormulaFieldTypeArrayFilterSupport, ReadOnlyFieldType):
|
||||||
def can_represent_select_options(self, field):
|
def can_represent_select_options(self, field):
|
||||||
return self.to_baserow_formula_type(field.specific).can_represent_select_options
|
return self.to_baserow_formula_type(field.specific).can_represent_select_options
|
||||||
|
|
||||||
|
def can_represent_collaborators(self, field):
|
||||||
|
return self.to_baserow_formula_type(field.specific).can_represent_collaborators
|
||||||
|
|
||||||
def get_permission_error_when_user_changes_field_to_depend_on_forbidden_field(
|
def get_permission_error_when_user_changes_field_to_depend_on_forbidden_field(
|
||||||
self, user: AbstractUser, changed_field: Field, forbidden_field: Field
|
self, user: AbstractUser, changed_field: Field, forbidden_field: Field
|
||||||
) -> Exception:
|
) -> Exception:
|
||||||
|
@ -5518,6 +5518,11 @@ class CountFieldType(FormulaFieldType):
|
||||||
target_field_name = target_field["name"]
|
target_field_name = target_field["name"]
|
||||||
return {(target_field_name, through_field_name)}
|
return {(target_field_name, through_field_name)}
|
||||||
|
|
||||||
|
def enhance_field_queryset(
|
||||||
|
self, queryset: QuerySet[Field], field: Field
|
||||||
|
) -> QuerySet[Field]:
|
||||||
|
return queryset.select_related("through_field")
|
||||||
|
|
||||||
|
|
||||||
class RollupFieldType(FormulaFieldType):
|
class RollupFieldType(FormulaFieldType):
|
||||||
type = "rollup"
|
type = "rollup"
|
||||||
|
@ -5722,6 +5727,11 @@ class RollupFieldType(FormulaFieldType):
|
||||||
target_field_name = target_field["name"]
|
target_field_name = target_field["name"]
|
||||||
return {(target_field_name, via_field_name)}
|
return {(target_field_name, via_field_name)}
|
||||||
|
|
||||||
|
def enhance_field_queryset(
|
||||||
|
self, queryset: QuerySet[Field], field: Field
|
||||||
|
) -> QuerySet[Field]:
|
||||||
|
return queryset.select_related("through_field", "target_field")
|
||||||
|
|
||||||
|
|
||||||
class LookupFieldType(FormulaFieldType):
|
class LookupFieldType(FormulaFieldType):
|
||||||
type = "lookup"
|
type = "lookup"
|
||||||
|
@ -6020,6 +6030,11 @@ class LookupFieldType(FormulaFieldType):
|
||||||
|
|
||||||
return {(target_field_name, via_field_name)}
|
return {(target_field_name, via_field_name)}
|
||||||
|
|
||||||
|
def enhance_field_queryset(
|
||||||
|
self, queryset: QuerySet[Field], field: Field
|
||||||
|
) -> QuerySet[Field]:
|
||||||
|
return queryset.select_related("through_field", "target_field")
|
||||||
|
|
||||||
|
|
||||||
class MultipleCollaboratorsFieldType(
|
class MultipleCollaboratorsFieldType(
|
||||||
CollationSortMixin, ManyToManyFieldTypeSerializeToInputValueMixin, FieldType
|
CollationSortMixin, ManyToManyFieldTypeSerializeToInputValueMixin, FieldType
|
||||||
|
@ -6027,11 +6042,15 @@ class MultipleCollaboratorsFieldType(
|
||||||
type = "multiple_collaborators"
|
type = "multiple_collaborators"
|
||||||
model_class = MultipleCollaboratorsField
|
model_class = MultipleCollaboratorsField
|
||||||
can_get_unique_values = False
|
can_get_unique_values = False
|
||||||
can_be_in_form_view = False
|
|
||||||
allowed_fields = ["notify_user_when_added"]
|
allowed_fields = ["notify_user_when_added"]
|
||||||
serializer_field_names = ["notify_user_when_added"]
|
serializer_field_names = ["available_collaborators", "notify_user_when_added"]
|
||||||
serializer_field_overrides = {
|
serializer_field_overrides = {
|
||||||
"notify_user_when_added": serializers.BooleanField(required=False)
|
"available_collaborators": serializers.ListField(
|
||||||
|
child=CollaboratorSerializer(),
|
||||||
|
read_only=True,
|
||||||
|
source="table.database.workspace.users.all",
|
||||||
|
),
|
||||||
|
"notify_user_when_added": serializers.BooleanField(required=False),
|
||||||
}
|
}
|
||||||
is_many_to_many_field = True
|
is_many_to_many_field = True
|
||||||
|
|
||||||
|
@ -6078,6 +6097,9 @@ class MultipleCollaboratorsFieldType(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def serialize_to_input_value(self, field: Field, value: any) -> any:
|
||||||
|
return [{"id": u.id, "name": u.first_name} for u in value.all()]
|
||||||
|
|
||||||
def prepare_value_for_db(self, instance, value):
|
def prepare_value_for_db(self, instance, value):
|
||||||
if not isinstance(
|
if not isinstance(
|
||||||
value,
|
value,
|
||||||
|
|
|
@ -17,7 +17,7 @@ from typing import (
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models import QuerySet
|
from django.db.models import Prefetch, QuerySet
|
||||||
from django.db.utils import DatabaseError, DataError, ProgrammingError
|
from django.db.utils import DatabaseError, DataError, ProgrammingError
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
@ -53,7 +53,7 @@ from baserow.contrib.database.table.models import Table
|
||||||
from baserow.contrib.database.views.handler import ViewHandler
|
from baserow.contrib.database.views.handler import ViewHandler
|
||||||
from baserow.core.db import specific_iterator
|
from baserow.core.db import specific_iterator
|
||||||
from baserow.core.handler import CoreHandler
|
from baserow.core.handler import CoreHandler
|
||||||
from baserow.core.models import TrashEntry
|
from baserow.core.models import TrashEntry, User
|
||||||
from baserow.core.telemetry.utils import baserow_trace_methods
|
from baserow.core.telemetry.utils import baserow_trace_methods
|
||||||
from baserow.core.trash.exceptions import RelatedTableTrashedException
|
from baserow.core.trash.exceptions import RelatedTableTrashedException
|
||||||
from baserow.core.trash.handler import TrashHandler
|
from baserow.core.trash.handler import TrashHandler
|
||||||
|
@ -239,6 +239,26 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
|
||||||
else:
|
else:
|
||||||
return filtered_qs
|
return filtered_qs
|
||||||
|
|
||||||
|
def get_base_fields_queryset(self) -> QuerySet[Field]:
|
||||||
|
"""
|
||||||
|
Returns a base queryset with proper select and prefetch related fields to use in
|
||||||
|
queries that need to fetch fields.
|
||||||
|
|
||||||
|
:return: A queryset with select and prefetch related fields set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return Field.objects.select_related(
|
||||||
|
"content_type", "table__database__workspace"
|
||||||
|
).prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"table__database__workspace__users",
|
||||||
|
queryset=User.objects.filter(profile__to_be_deleted=False).order_by(
|
||||||
|
"first_name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"select_options",
|
||||||
|
)
|
||||||
|
|
||||||
def get_fields(
|
def get_fields(
|
||||||
self,
|
self,
|
||||||
table: Table,
|
table: Table,
|
||||||
|
|
|
@ -1786,6 +1786,11 @@ class FieldType(
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def can_represent_collaborators(self, field):
|
||||||
|
"""Indicates whether the field can be used to represent collaborators."""
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def get_permission_error_when_user_changes_field_to_depend_on_forbidden_field(
|
def get_permission_error_when_user_changes_field_to_depend_on_forbidden_field(
|
||||||
self, user: AbstractUser, changed_field: Field, forbidden_field: Field
|
self, user: AbstractUser, changed_field: Field, forbidden_field: Field
|
||||||
) -> Exception:
|
) -> Exception:
|
||||||
|
|
|
@ -277,6 +277,10 @@ class BaserowFormulaType(abc.ABC):
|
||||||
def can_represent_select_options(self) -> bool:
|
def can_represent_select_options(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_represent_collaborators(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def item_is_in_nested_value_object_when_in_array(self) -> bool:
|
def item_is_in_nested_value_object_when_in_array(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -1339,12 +1339,17 @@ class BaserowFormulaArrayType(
|
||||||
def can_represent_select_options(self, field) -> bool:
|
def can_represent_select_options(self, field) -> bool:
|
||||||
return self.sub_type.can_represent_select_options(field)
|
return self.sub_type.can_represent_select_options(field)
|
||||||
|
|
||||||
|
def can_represent_collaborators(self, field):
|
||||||
|
return self.sub_type.can_represent_collaborators(field)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_serializer_field_overrides(cls):
|
def get_serializer_field_overrides(cls):
|
||||||
from baserow.contrib.database.api.fields.serializers import (
|
from baserow.contrib.database.api.fields.serializers import (
|
||||||
|
CollaboratorSerializer,
|
||||||
SelectOptionSerializer,
|
SelectOptionSerializer,
|
||||||
)
|
)
|
||||||
from baserow.contrib.database.api.formula.serializers import (
|
from baserow.contrib.database.api.formula.serializers import (
|
||||||
|
BaserowFormulaCollaboratorsSerializer,
|
||||||
BaserowFormulaSelectOptionsSerializer,
|
BaserowFormulaSelectOptionsSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1354,7 +1359,13 @@ class BaserowFormulaArrayType(
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
)
|
),
|
||||||
|
"available_collaborators": BaserowFormulaCollaboratorsSerializer(
|
||||||
|
child=CollaboratorSerializer(),
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
read_only=True,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_filter_value(self, field, model_field, value):
|
def parse_filter_value(self, field, model_field, value):
|
||||||
|
@ -1656,7 +1667,7 @@ class BaserowFormulaMultipleCollaboratorsType(BaserowJSONBObjectBaseType):
|
||||||
return "p_in = '';"
|
return "p_in = '';"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_represent_select_options(self) -> bool:
|
def can_represent_collaborators(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -1683,12 +1694,13 @@ class BaserowFormulaMultipleCollaboratorsType(BaserowJSONBObjectBaseType):
|
||||||
setattr(
|
setattr(
|
||||||
field,
|
field,
|
||||||
cache_key,
|
cache_key,
|
||||||
list(field.table.database.workspace.users.order_by("id").all()),
|
{usr.id: usr for usr in field.table.database.workspace.users.all()},
|
||||||
)
|
)
|
||||||
|
|
||||||
user_ids = set((item["id"] for item in value))
|
# Replace the JSON object with the actual user object, so we have
|
||||||
workspace_users = getattr(field, cache_key)
|
# access to the user's email.
|
||||||
value = [user for user in workspace_users if user.id in user_ids]
|
users = getattr(field, cache_key)
|
||||||
|
value = [users[item["id"]] for item in value]
|
||||||
|
|
||||||
return field_type.get_export_value(value, field_object, rich_value=rich_value)
|
return field_type.get_export_value(value, field_object, rich_value=rich_value)
|
||||||
|
|
||||||
|
@ -1764,6 +1776,28 @@ class BaserowFormulaMultipleCollaboratorsType(BaserowJSONBObjectBaseType):
|
||||||
|
|
||||||
return "first_name"
|
return "first_name"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_serializer_field_overrides(cls):
|
||||||
|
from baserow.contrib.database.api.fields.serializers import (
|
||||||
|
CollaboratorSerializer,
|
||||||
|
)
|
||||||
|
from baserow.contrib.database.api.formula.serializers import (
|
||||||
|
BaserowFormulaCollaboratorsSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available_collaborators": BaserowFormulaCollaboratorsSerializer(
|
||||||
|
child=CollaboratorSerializer(),
|
||||||
|
allow_null=True,
|
||||||
|
required=False,
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_serializer_field_names(cls) -> List[str]:
|
||||||
|
return super().all_fields() + ["available_collaborators"]
|
||||||
|
|
||||||
|
|
||||||
BASEROW_FORMULA_TYPES = [
|
BASEROW_FORMULA_TYPES = [
|
||||||
BaserowFormulaInvalidType,
|
BaserowFormulaInvalidType,
|
||||||
|
|
|
@ -20,7 +20,7 @@ from typing import (
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import DEFAULT_DB_ALIAS, connection, transaction
|
from django.db import DEFAULT_DB_ALIAS, connection, transaction
|
||||||
from django.db.models import ForeignKey, ManyToManyField, Max, Model, QuerySet
|
from django.db.models import ForeignKey, ManyToManyField, Max, Model, Prefetch, QuerySet
|
||||||
from django.db.models.functions import Collate
|
from django.db.models.functions import Collate
|
||||||
from django.db.models.sql.query import LOOKUP_SEP
|
from django.db.models.sql.query import LOOKUP_SEP
|
||||||
from django.db.transaction import Atomic, get_connection
|
from django.db.transaction import Atomic, get_connection
|
||||||
|
@ -110,12 +110,14 @@ def specific_iterator(
|
||||||
if isinstance(select_related, bool):
|
if isinstance(select_related, bool):
|
||||||
select_related_keys = []
|
select_related_keys = []
|
||||||
else:
|
else:
|
||||||
select_related_keys = select_related.keys()
|
select_related_keys = list(select_related.keys())
|
||||||
|
|
||||||
# Nested prefetch result in cached objects to avoid additional queries. If
|
# Nested prefetch result in cached objects to avoid additional queries. If
|
||||||
# they're present, they must be added to the `select_related_keys` to make sure
|
# they're present, they must be added to the `select_related_keys` to make sure
|
||||||
# they're correctly set on the specific objects.
|
# they're correctly set on the specific objects.
|
||||||
for lookup in queryset_or_list._prefetch_related_lookups:
|
for lookup in queryset_or_list._prefetch_related_lookups:
|
||||||
|
if isinstance(lookup, Prefetch):
|
||||||
|
lookup = lookup.prefetch_through
|
||||||
split_lookup = lookup.split(LOOKUP_SEP)[:-1]
|
split_lookup = lookup.split(LOOKUP_SEP)[:-1]
|
||||||
if split_lookup and split_lookup[0] not in select_related_keys:
|
if split_lookup and split_lookup[0] not in select_related_keys:
|
||||||
select_related_keys.append(split_lookup[0])
|
select_related_keys.append(split_lookup[0])
|
||||||
|
|
|
@ -74,6 +74,7 @@
|
||||||
<mj-class
|
<mj-class
|
||||||
name="notification-description"
|
name="notification-description"
|
||||||
font-size="12px"
|
font-size="12px"
|
||||||
|
line-height="18px"
|
||||||
color="#838387"
|
color="#838387"
|
||||||
font-family="Inter,sans-serif"
|
font-family="Inter,sans-serif"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -206,7 +206,7 @@
|
||||||
<!-- htmlmin:ignore -->
|
<!-- htmlmin:ignore -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Inter,sans-serif;font-size:12px;line-height:1;text-align:left;color:#838387;">{{ notification.description|linebreaksbr }}</div>
|
<div style="font-family:Inter,sans-serif;font-size:12px;line-height:18px;text-align:left;color:#838387;">{{ notification.description|linebreaksbr }}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<!-- htmlmin:ignore -->{% endif %}
|
<!-- htmlmin:ignore -->{% endif %}
|
||||||
|
@ -225,7 +225,7 @@
|
||||||
<!-- htmlmin:ignore -->
|
<!-- htmlmin:ignore -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div style="font-family:Inter,sans-serif;font-size:12px;line-height:1;text-align:left;color:#838387;">{% blocktrans trimmed count counter=unlisted_notifications_count %} Plus {{ counter }} more notification. {% plural %} Plus {{ counter }} more notifications. {% endblocktrans %}</div>
|
<div style="font-family:Inter,sans-serif;font-size:12px;line-height:18px;text-align:left;color:#838387;">{% blocktrans trimmed count counter=unlisted_notifications_count %} Plus {{ counter }} more notification. {% plural %} Plus {{ counter }} more notifications. {% endblocktrans %}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<!-- htmlmin:ignore -->{% endif %}
|
<!-- htmlmin:ignore -->{% endif %}
|
||||||
|
|
|
@ -970,7 +970,6 @@ def test_can_type_a_valid_formula_field(data_fixture, api_client):
|
||||||
"number_prefix": "",
|
"number_prefix": "",
|
||||||
"number_separator": "",
|
"number_separator": "",
|
||||||
"number_suffix": "",
|
"number_suffix": "",
|
||||||
"select_options": [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,10 @@ def test_multiple_collaborators_field_type_create(api_client, data_fixture):
|
||||||
assert response.status_code == HTTP_200_OK
|
assert response.status_code == HTTP_200_OK
|
||||||
assert response_json["name"] == "Collaborator 1"
|
assert response_json["name"] == "Collaborator 1"
|
||||||
assert response_json["type"] == "multiple_collaborators"
|
assert response_json["type"] == "multiple_collaborators"
|
||||||
|
assert response_json["notify_user_when_added"] is False
|
||||||
|
assert response_json["available_collaborators"] == [
|
||||||
|
{"id": user.id, "name": user.first_name}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.field_multiple_collaborators
|
@pytest.mark.field_multiple_collaborators
|
||||||
|
@ -43,6 +47,7 @@ def test_multiple_collaborators_field_type_update(api_client, data_fixture):
|
||||||
first_name="Test1",
|
first_name="Test1",
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
)
|
)
|
||||||
|
user_2 = data_fixture.create_user(workspace=workspace, first_name="Test2")
|
||||||
database = data_fixture.create_database_application(
|
database = data_fixture.create_database_application(
|
||||||
user=user, name="Placeholder", workspace=workspace
|
user=user, name="Placeholder", workspace=workspace
|
||||||
)
|
)
|
||||||
|
@ -62,6 +67,10 @@ def test_multiple_collaborators_field_type_update(api_client, data_fixture):
|
||||||
assert response.status_code == HTTP_200_OK
|
assert response.status_code == HTTP_200_OK
|
||||||
assert response_json["name"] == "New collaborator 1"
|
assert response_json["name"] == "New collaborator 1"
|
||||||
assert response_json["type"] == "multiple_collaborators"
|
assert response_json["type"] == "multiple_collaborators"
|
||||||
|
assert response_json["available_collaborators"] == [
|
||||||
|
{"id": user.id, "name": user.first_name},
|
||||||
|
{"id": user_2.id, "name": user_2.first_name},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.field_multiple_collaborators
|
@pytest.mark.field_multiple_collaborators
|
||||||
|
|
|
@ -1361,12 +1361,25 @@ def test_form_view_multiple_collaborators_field_options(api_client, data_fixture
|
||||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||||
)
|
)
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
assert response.status_code == HTTP_200_OK
|
||||||
assert response_json["error"] == "ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED"
|
assert response_json == {
|
||||||
assert (
|
"field_options": {
|
||||||
response_json["detail"]
|
str(multiple_collaborators_field.id): {
|
||||||
== "The multiple_collaborators field type is not compatible with the form view."
|
"name": "",
|
||||||
)
|
"description": "",
|
||||||
|
"enabled": True,
|
||||||
|
"required": True,
|
||||||
|
"order": 32767,
|
||||||
|
"show_when_matching_conditions": False,
|
||||||
|
"condition_type": "AND",
|
||||||
|
"condition_groups": [],
|
||||||
|
"conditions": [],
|
||||||
|
"field_component": "default",
|
||||||
|
"include_all_select_options": True,
|
||||||
|
"allowed_select_options": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|
|
@ -2,7 +2,7 @@ from unittest.mock import MagicMock
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models import CharField, Value
|
from django.db.models import CharField, Prefetch, Value
|
||||||
from django.db.models.expressions import ExpressionWrapper
|
from django.db.models.expressions import ExpressionWrapper
|
||||||
from django.db.models.functions import Concat
|
from django.db.models.functions import Concat
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
|
@ -17,7 +17,12 @@ from baserow.contrib.database.fields.models import (
|
||||||
TextField,
|
TextField,
|
||||||
)
|
)
|
||||||
from baserow.contrib.database.rows.handler import RowHandler
|
from baserow.contrib.database.rows.handler import RowHandler
|
||||||
from baserow.contrib.database.views.models import GalleryView, GridView, View
|
from baserow.contrib.database.views.models import (
|
||||||
|
GalleryView,
|
||||||
|
GridView,
|
||||||
|
View,
|
||||||
|
ViewFilter,
|
||||||
|
)
|
||||||
from baserow.core.db import (
|
from baserow.core.db import (
|
||||||
CombinedForeignKeyAndManyToManyMultipleFieldPrefetch,
|
CombinedForeignKeyAndManyToManyMultipleFieldPrefetch,
|
||||||
LockedAtomicTransaction,
|
LockedAtomicTransaction,
|
||||||
|
@ -192,8 +197,12 @@ def test_specific_iterator_with_select_related(data_fixture, django_assert_num_q
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"prefetch_related",
|
||||||
|
["viewfilter_set", Prefetch("viewfilter_set", queryset=ViewFilter.objects.all())],
|
||||||
|
)
|
||||||
def test_specific_iterator_with_prefetch_related(
|
def test_specific_iterator_with_prefetch_related(
|
||||||
data_fixture, django_assert_num_queries
|
prefetch_related, data_fixture, django_assert_num_queries
|
||||||
):
|
):
|
||||||
grid_view = data_fixture.create_grid_view()
|
grid_view = data_fixture.create_grid_view()
|
||||||
gallery_view = data_fixture.create_gallery_view()
|
gallery_view = data_fixture.create_gallery_view()
|
||||||
|
@ -209,7 +218,7 @@ def test_specific_iterator_with_prefetch_related(
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
.order_by("id")
|
.order_by("id")
|
||||||
.prefetch_related("viewfilter_set")
|
.prefetch_related(prefetch_related)
|
||||||
)
|
)
|
||||||
|
|
||||||
with django_assert_num_queries(4):
|
with django_assert_num_queries(4):
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"type": "feature",
|
||||||
|
"message": "Add support for collaborator fields in form views.",
|
||||||
|
"issue_number": 1554,
|
||||||
|
"bullet_points": [],
|
||||||
|
"created_at": "2025-02-12"
|
||||||
|
}
|
|
@ -144,7 +144,9 @@
|
||||||
"singleSelectRadios": "Radios",
|
"singleSelectRadios": "Radios",
|
||||||
"autonumber": "Autonumber",
|
"autonumber": "Autonumber",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"ai": "AI prompt"
|
"ai": "AI prompt",
|
||||||
|
"multipleCollaboratorsDropdown": "Dropdown",
|
||||||
|
"multipleCollaboratorsCheckboxes": "Checkboxes"
|
||||||
},
|
},
|
||||||
"fieldErrors": {
|
"fieldErrors": {
|
||||||
"invalidNumber": "Invalid number",
|
"invalidNumber": "Invalid number",
|
||||||
|
|
|
@ -59,6 +59,7 @@
|
||||||
|
|
||||||
.notification-panel__notification-content {
|
.notification-panel__notification-content {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
max-width: calc(100% - 34px - 16px); // 34px for icon, 16px for status
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
@ -105,6 +106,13 @@
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-panel__notification-content-summary-item {
|
||||||
|
@extend %ellipsis;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-panel__notification-status {
|
.notification-panel__notification-status {
|
||||||
flex: 0 0 16px;
|
flex: 0 0 16px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|
|
@ -16,7 +16,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
search() {
|
search() {
|
||||||
this.fetch(this.page, this.query)
|
this.fetch(1, this.query)
|
||||||
},
|
},
|
||||||
hide() {
|
hide() {
|
||||||
// Call the dropdown `hide` method because it resets the search. We're
|
// Call the dropdown `hide` method because it resets the search. We're
|
||||||
|
|
|
@ -125,7 +125,12 @@ export default {
|
||||||
if (replace) {
|
if (replace) {
|
||||||
this.results = results
|
this.results = results
|
||||||
} else {
|
} else {
|
||||||
this.results.push(...results)
|
const resultIds = new Set(this.results.map((res) => res.id))
|
||||||
|
this.results.push(
|
||||||
|
...results.filter((res) => {
|
||||||
|
return !resultIds.has(res.id)
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.count = count
|
this.count = count
|
||||||
|
|
|
@ -65,9 +65,9 @@
|
||||||
></FieldCollaboratorDropdownItem>
|
></FieldCollaboratorDropdownItem>
|
||||||
<FieldCollaboratorDropdownItem
|
<FieldCollaboratorDropdownItem
|
||||||
v-for="collaborator in results"
|
v-for="collaborator in results"
|
||||||
:key="collaborator.user_id"
|
:key="collaborator.id"
|
||||||
:name="collaborator.name"
|
:name="collaborator.name"
|
||||||
:value="collaborator.user_id"
|
:value="collaborator.id"
|
||||||
></FieldCollaboratorDropdownItem>
|
></FieldCollaboratorDropdownItem>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="isNotFound" class="select__description">
|
<div v-if="isNotFound" class="select__description">
|
||||||
|
@ -95,6 +95,11 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
pageSize: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
default: 100, // override default pageSize of 20
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isNotFound() {
|
isNotFound() {
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
<div class="notification-panel__notification-content-desc">
|
<div class="notification-panel__notification-content-desc">
|
||||||
<ul class="notification-panel__notification-content-summary">
|
<ul class="notification-panel__notification-content-summary">
|
||||||
<li v-for="(elem, index) in submittedValuesSummary" :key="index">
|
<li v-for="(elem, index) in submittedValuesSummary" :key="index">
|
||||||
{{ elem.field }}: {{ elem.value }}
|
<span class="notification-panel__notification-content-summary-item"
|
||||||
|
>{{ elem.field }}: {{ elem.value }}</span
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="hiddenFieldsCount > 0">
|
<div v-if="hiddenFieldsCount > 0">
|
||||||
|
|
|
@ -27,7 +27,7 @@ export default {
|
||||||
},
|
},
|
||||||
initialDisplayName() {
|
initialDisplayName() {
|
||||||
const selected = this.workspaceCollaborators.find(
|
const selected = this.workspaceCollaborators.find(
|
||||||
(c) => c.user_id === this.copy
|
(c) => c.id === this.copy
|
||||||
)
|
)
|
||||||
return selected?.name
|
return selected?.name
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
<template>
|
||||||
|
<div class="control__elements">
|
||||||
|
<div
|
||||||
|
v-if="field.available_collaborators.length === 0"
|
||||||
|
class="control--messages"
|
||||||
|
>
|
||||||
|
<p>{{ $t('formViewField.noCollaboratorsAvailable') }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-for="option in field.available_collaborators" :key="option.id">
|
||||||
|
<Checkbox
|
||||||
|
:checked="value.findIndex((o) => o.id === option.id) !== -1"
|
||||||
|
class="margin-bottom-1"
|
||||||
|
@input=";[touch(), toggleValue(option.id, value)]"
|
||||||
|
>{{ option.name }}</Checkbox
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="!required" class="margin-top-1">
|
||||||
|
<a @click=";[touch(), clear(value)]">clear value</a>
|
||||||
|
</div>
|
||||||
|
<div v-show="touched && !valid" class="error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
|
||||||
|
import collaboratorField from '@baserow/modules/database/mixins/collaboratorField'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'FormViewFieldMultipleCollaboratorsCheckboxes',
|
||||||
|
mixins: [rowEditField, collaboratorField],
|
||||||
|
methods: {
|
||||||
|
toggleValue(id, oldValue) {
|
||||||
|
const index = oldValue.findIndex((option) => option.id === id)
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
this.updateValue(id, oldValue)
|
||||||
|
} else {
|
||||||
|
this.removeValue(null, oldValue, id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear(oldValue) {
|
||||||
|
const newValue = []
|
||||||
|
|
||||||
|
if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
|
||||||
|
this.$emit('update', newValue, oldValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -138,6 +138,7 @@ import RowHistoryFieldPassword from '@baserow/modules/database/components/row/Ro
|
||||||
import FormViewFieldLinkRow from '@baserow/modules/database/components/view/form/FormViewFieldLinkRow'
|
import FormViewFieldLinkRow from '@baserow/modules/database/components/view/form/FormViewFieldLinkRow'
|
||||||
import FormViewFieldMultipleLinkRow from '@baserow/modules/database/components/view/form/FormViewFieldMultipleLinkRow'
|
import FormViewFieldMultipleLinkRow from '@baserow/modules/database/components/view/form/FormViewFieldMultipleLinkRow'
|
||||||
import FormViewFieldMultipleSelectCheckboxes from '@baserow/modules/database/components/view/form/FormViewFieldMultipleSelectCheckboxes'
|
import FormViewFieldMultipleSelectCheckboxes from '@baserow/modules/database/components/view/form/FormViewFieldMultipleSelectCheckboxes'
|
||||||
|
import FormViewFieldMultipleCollaboratorsCheckboxes from '@baserow/modules/database/components/view/form/FormViewFieldMultipleCollaboratorsCheckboxes'
|
||||||
import FormViewFieldSingleSelectRadios from '@baserow/modules/database/components/view/form/FormViewFieldSingleSelectRadios'
|
import FormViewFieldSingleSelectRadios from '@baserow/modules/database/components/view/form/FormViewFieldSingleSelectRadios'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -4159,7 +4160,20 @@ export class MultipleCollaboratorsFieldType extends FieldType {
|
||||||
}
|
}
|
||||||
|
|
||||||
getFormViewFieldComponents(field) {
|
getFormViewFieldComponents(field) {
|
||||||
return {}
|
const { i18n } = this.app
|
||||||
|
const components = super.getFormViewFieldComponents(field)
|
||||||
|
components[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY].name = i18n.t(
|
||||||
|
'fieldType.multipleCollaboratorsDropdown'
|
||||||
|
)
|
||||||
|
components[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY].properties = {
|
||||||
|
'allow-create-options': false,
|
||||||
|
}
|
||||||
|
components.checkboxes = {
|
||||||
|
name: i18n.t('fieldType.multipleCollaboratorsCheckboxes'),
|
||||||
|
component: FormViewFieldMultipleCollaboratorsCheckboxes,
|
||||||
|
properties: {},
|
||||||
|
}
|
||||||
|
return components
|
||||||
}
|
}
|
||||||
|
|
||||||
getEmptyValue() {
|
getEmptyValue() {
|
||||||
|
|
|
@ -801,7 +801,8 @@
|
||||||
"addCondition": "Add condition",
|
"addCondition": "Add condition",
|
||||||
"addConditionGroup": "Add condition group",
|
"addConditionGroup": "Add condition group",
|
||||||
"showFieldAs": "Show field as",
|
"showFieldAs": "Show field as",
|
||||||
"noSelectOptions": "There are no select options available."
|
"noSelectOptions": "There are no select options available.",
|
||||||
|
"noCollaboratorsAvailable": "There are no collaborators available."
|
||||||
},
|
},
|
||||||
"duplicateFieldContext": {
|
"duplicateFieldContext": {
|
||||||
"duplicate": "Duplicate field",
|
"duplicate": "Duplicate field",
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
export default {
|
export default {
|
||||||
computed: {
|
computed: {
|
||||||
workspaceCollaborators() {
|
workspaceCollaborators() {
|
||||||
const workspace = this.$store.getters['workspace/getSelected']
|
return this.field.available_collaborators
|
||||||
return workspace.users.filter((user) => user.to_be_deleted === false)
|
|
||||||
},
|
},
|
||||||
availableCollaborators() {
|
availableCollaborators() {
|
||||||
// When converting from a CollaboratorField to another field it can happen
|
// When converting from a CollaboratorField to another field it can happen
|
||||||
|
@ -15,7 +14,7 @@ export default {
|
||||||
|
|
||||||
const ids = new Set(this.value.map((item) => item.id))
|
const ids = new Set(this.value.map((item) => item.id))
|
||||||
const result = this.workspaceCollaborators.filter(
|
const result = this.workspaceCollaborators.filter(
|
||||||
(item) => !ids.has(item.user_id)
|
(item) => !ids.has(item.id)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|
|
@ -25,13 +25,13 @@ export default {
|
||||||
|
|
||||||
const workspaceUser =
|
const workspaceUser =
|
||||||
this.workspaceCollaborators.find(
|
this.workspaceCollaborators.find(
|
||||||
(workspaceUser) => workspaceUser.user_id === newId
|
(workspaceUser) => workspaceUser.id === newId
|
||||||
) || null
|
) || null
|
||||||
|
|
||||||
let newOption = null
|
let newOption = null
|
||||||
if (workspaceUser) {
|
if (workspaceUser) {
|
||||||
newOption = {
|
newOption = {
|
||||||
id: workspaceUser.user_id,
|
id: workspaceUser.id,
|
||||||
name: workspaceUser.name,
|
name: workspaceUser.name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2445,10 +2445,6 @@ export class MultipleCollaboratorsHasFilterType extends ViewFilterType {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
isAllowedInPublicViews() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
matches(rowValue, filterValue, field, fieldType) {
|
matches(rowValue, filterValue, field, fieldType) {
|
||||||
if (!isNumeric(filterValue)) {
|
if (!isNumeric(filterValue)) {
|
||||||
return true
|
return true
|
||||||
|
@ -2484,10 +2480,6 @@ export class MultipleCollaboratorsHasNotFilterType extends ViewFilterType {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
isAllowedInPublicViews() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
matches(rowValue, filterValue, field, fieldType) {
|
matches(rowValue, filterValue, field, fieldType) {
|
||||||
if (!isNumeric(filterValue)) {
|
if (!isNumeric(filterValue)) {
|
||||||
return true
|
return true
|
||||||
|
|
Loading…
Add table
Reference in a new issue