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

Resolve "Kanban view"

This commit is contained in:
Bram Wiepjes 2021-11-23 16:48:51 +00:00
parent 931a88a195
commit fd246a3e74
98 changed files with 6229 additions and 166 deletions
backend/src/baserow
config/settings
contrib/database
premium
web-frontend/modules

View file

@ -236,6 +236,7 @@ SPECTACULAR_SETTINGS = {
{"name": "Database table view sortings"},
{"name": "Database table grid view"},
{"name": "Database table form view"},
{"name": "Database table kanban view"},
{"name": "Database table rows"},
{"name": "Database table export"},
{"name": "Database table webhooks"},

View file

@ -225,10 +225,10 @@ class ViewsView(APIView):
"""Creates a new view for a user."""
type_name = data.pop("type")
field_type = view_type_registry.get(type_name)
view_type = view_type_registry.get(type_name)
table = TableHandler().get_table(table_id)
with field_type.map_api_exceptions():
with view_type.map_api_exceptions():
view = ViewHandler().create_view(request.user, table, type_name, **data)
serializer = view_type_registry.get_serializer(

View file

@ -295,7 +295,7 @@ class FieldType(
"""
The prepare_values hook gives the possibility to change the provided values
that just before they are going to be used to create or update the instance. For
example if an ID is provided it can be converted to a model instance. Or to
example if an ID is provided, it can be converted to a model instance. Or to
convert a certain date string to a date object.
:param values: The provided values.

View file

@ -106,18 +106,20 @@ class ViewHandler:
# Figure out which model to use for the given view type.
view_type = view_type_registry.get(type_name)
model_class = view_type.model_class
view_values = view_type.prepare_values(kwargs, table, user)
allowed_fields = [
"name",
"filter_type",
"filters_disabled",
] + view_type.allowed_fields
view_values = extract_allowed(kwargs, allowed_fields)
view_values = extract_allowed(view_values, allowed_fields)
last_order = model_class.get_last_order(table)
instance = model_class.objects.create(
table=table, order=last_order, **view_values
)
view_type.view_created(view=instance)
view_created.send(self, view=instance, user=user, type_name=type_name)
return instance
@ -144,12 +146,13 @@ class ViewHandler:
group.has_user(user, raise_error=True)
view_type = view_type_registry.get_by_model(view)
view_values = view_type.prepare_values(kwargs, view.table, user)
allowed_fields = [
"name",
"filter_type",
"filters_disabled",
] + view_type.allowed_fields
view = set_allowed_attrs(kwargs, allowed_fields, view)
view = set_allowed_attrs(view_values, allowed_fields, view)
view.save()
view_updated.send(self, view=view, user=user)

View file

@ -1,5 +1,7 @@
from typing import Callable, Union, List
from django.contrib.auth.models import User as DjangoUser
from rest_framework.serializers import Serializer
from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ
@ -282,6 +284,30 @@ class ViewType(
return field_options
def prepare_values(self, values: dict, table, user: DjangoUser):
"""
The prepare_values hook gives the possibility to change the provided values
just before they are going to be used to create or update the instance. For
example if an ID is provided, it can be converted to a model instance. Or to
convert a certain date string to a date object.
:param values: The provided values.
:param table: The table where the view is created in.
:type table: Table
:param user: The user on whose behalf the change is made.
:return: The updates values.
:type: dict
"""
return values
def view_created(self, view):
"""
A hook that's called when a new view is created.
:param view: The newly created view instance.
"""
class ViewTypeRegistry(
APIUrlsRegistryMixin, CustomFieldsRegistryMixin, ModelRegistryMixin, Registry

View file

@ -73,7 +73,9 @@ class GroupAdminView(APIView):
],
responses={
204: None,
400: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
400: get_error_schema(
["ERROR_GROUP_DOES_NOT_EXIST", "ERROR_NO_ACTIVE_PREMIUM_LICENSE"]
),
401: None,
},
)

View file

@ -89,6 +89,7 @@ class UserAdminView(APIView):
"ERROR_REQUEST_BODY_VALIDATION",
"USER_ADMIN_CANNOT_DEACTIVATE_SELF",
"USER_ADMIN_UNKNOWN_USER",
"ERROR_NO_ACTIVE_PREMIUM_LICENSE",
]
),
401: None,
@ -134,6 +135,7 @@ class UserAdminView(APIView):
[
"USER_ADMIN_CANNOT_DELETE_SELF",
"USER_ADMIN_UNKNOWN_USER",
"ERROR_NO_ACTIVE_PREMIUM_LICENSE",
]
),
401: None,

View file

@ -0,0 +1,25 @@
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
ERROR_KANBAN_DOES_NOT_EXIST = (
"ERROR_KANBAN_DOES_NOT_EXIST",
HTTP_404_NOT_FOUND,
"The requested kanban view does not exist.",
)
ERROR_KANBAN_VIEW_HAS_NO_SINGLE_SELECT_FIELD = (
"ERROR_KANBAN_VIEW_HAS_NO_SINGLE_SELECT_FIELD",
HTTP_400_BAD_REQUEST,
"The requested kanban view does not have a single select option field.",
)
ERROR_INVALID_SELECT_OPTION_PARAMETER = (
"ERROR_INVALID_SELECT_OPTION_PARAMETER",
HTTP_400_BAD_REQUEST,
"The provided select option parameter {e.select_option_name} is invalid. The "
"following structure is expected ?select_option=id,limit,offset (e.g. "
"?select_option=1,2,3).",
)
ERROR_KANBAN_VIEW_FIELD_DOES_NOT_BELONG_TO_SAME_TABLE = (
"ERROR_KANBAN_VIEW_FIELD_DOES_NOT_BELONG_TO_SAME_TABLE",
HTTP_400_BAD_REQUEST,
"The provided single select field does not belong to the same table.",
)

View file

@ -0,0 +1,6 @@
class InvalidSelectOptionParameter(Exception):
"""Raised when an invalid select option query parameter is provided."""
def __init__(self, select_option_name, *args, **kwargs):
self.select_option_name = select_option_name
super().__init__(*args, **kwargs)

View file

@ -0,0 +1,32 @@
from rest_framework import serializers
from baserow.contrib.database.api.rows.serializers import (
get_example_row_serializer_class,
)
from baserow_premium.views.models import KanbanViewFieldOptions
class KanbanViewFieldOptionsSerializer(serializers.ModelSerializer):
class Meta:
model = KanbanViewFieldOptions
fields = ("hidden", "order")
class KanbanViewExampleResponseStackSerializer(serializers.Serializer):
count = serializers.IntegerField(
help_text="The total count of rows that are included in this group."
)
results = serializers.ListSerializer(
help_text="All the rows that belong in this group related with the provided "
"`limit` and `offset`.",
child=get_example_row_serializer_class(True, False)(),
)
class KanbanViewExampleResponseSerializer(serializers.Serializer):
OPTION_ID = KanbanViewExampleResponseStackSerializer(
help_text="Every select option related to the view's single select field can "
"have its own entry like this."
)
field_options = serializers.ListSerializer(child=KanbanViewFieldOptionsSerializer())

View file

@ -0,0 +1,10 @@
from django.urls import re_path
from .views import KanbanViewView
app_name = "baserow_premium.api.views.kanban"
urlpatterns = [
re_path(r"(?P<view_id>[0-9]+)/$", KanbanViewView.as_view(), name="list"),
]

View file

@ -0,0 +1,194 @@
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from baserow.api.decorators import (
map_exceptions,
allowed_includes,
)
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
from baserow.api.schemas import get_error_schema
from baserow.contrib.database.api.rows.serializers import (
get_row_serializer_class,
RowSerializer,
)
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.registries import view_type_registry
from baserow.core.exceptions import UserNotInGroup
from baserow_premium.views.models import KanbanView
from baserow_premium.views.exceptions import KanbanViewHasNoSingleSelectField
from baserow_premium.license.handler import check_active_premium_license
from baserow_premium.views.handler import get_rows_grouped_by_single_select_field
from .errors import (
ERROR_KANBAN_DOES_NOT_EXIST,
ERROR_KANBAN_VIEW_HAS_NO_SINGLE_SELECT_FIELD,
ERROR_INVALID_SELECT_OPTION_PARAMETER,
)
from .exceptions import InvalidSelectOptionParameter
from .serializers import (
KanbanViewExampleResponseSerializer,
)
class KanbanViewView(APIView):
permission_classes = (AllowAny,)
@extend_schema(
parameters=[
OpenApiParameter(
name="view_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="Returns only rows that belong to the related view's "
"table.",
),
OpenApiParameter(
name="include",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Accepts `field_options` as value if the field options "
"must also be included in the response.",
),
OpenApiParameter(
name="limit",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Defines how many rows should be returned by default. "
"This value can be overwritten per select option.",
),
OpenApiParameter(
name="offset",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Defines from which offset the rows should be returned."
"This value can be overwritten per select option.",
),
OpenApiParameter(
name="select_option",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
"Accepts multiple `select_option` parameters. If not provided, the "
"rows of all select options will be returned. If one or more "
"`select_option` parameters are provided, then only the rows of "
"those will be included in the response. "
"`?select_option=1&select_option=null` will only include the rows "
"for both select option with id `1` and `null`. "
"`?select_option=1,10,20` will only include the rows of select "
"option id `1` with a limit of `10` and and offset of `20`."
),
),
],
tags=["Database table kanban view"],
operation_id="list_database_table_kanban_view_rows",
description=(
"Responds with serialized rows grouped by the view's single select field "
"options if the user is authenticated and has access to the related "
"group. Additional query parameters can be provided to control the "
"`limit` and `offset` per select option."
"\n\nThis is a **premium** feature."
),
responses={
200: KanbanViewExampleResponseSerializer,
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_KANBAN_VIEW_HAS_NO_SINGLE_SELECT_FIELD",
"ERROR_INVALID_SELECT_OPTION_PARAMETER",
"ERROR_NO_ACTIVE_PREMIUM_LICENSE",
]
),
404: get_error_schema(["ERROR_KANBAN_DOES_NOT_EXIST"]),
},
)
@map_exceptions(
{
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
ViewDoesNotExist: ERROR_KANBAN_DOES_NOT_EXIST,
KanbanViewHasNoSingleSelectField: (
ERROR_KANBAN_VIEW_HAS_NO_SINGLE_SELECT_FIELD
),
InvalidSelectOptionParameter: ERROR_INVALID_SELECT_OPTION_PARAMETER,
}
)
@allowed_includes("field_options")
def get(self, request, view_id, field_options):
"""Responds with the rows grouped by the view's select option field value."""
view_handler = ViewHandler()
view = view_handler.get_view(view_id, KanbanView)
group = view.table.database.group
# We don't want to check if there is an active premium license if the group
# is a template because that feature must then be available for demo purposes.
if not group.has_template():
check_active_premium_license(request.user)
group.has_user(request.user, raise_error=True, allow_if_template=True)
single_select_option_field = view.single_select_field
if not single_select_option_field:
raise KanbanViewHasNoSingleSelectField(
"The requested kanban view does not have a required single select "
"option field."
)
# Parse the provided select options from the query parameters. It's possible
# to only fetch the rows of specific field options and also
all_select_options = request.GET.getlist("select_option")
included_select_options = {}
for select_option in all_select_options:
splitted = select_option.split(",")
try:
included_select_options[splitted[0]] = {}
if 1 < len(splitted):
included_select_options[splitted[0]]["limit"] = int(splitted[1])
if 2 < len(splitted):
included_select_options[splitted[0]]["offset"] = int(splitted[2])
except ValueError:
raise InvalidSelectOptionParameter(splitted[0])
default_limit = 40
default_offset = 0
try:
default_limit = int(request.GET["limit"])
except (KeyError, ValueError):
pass
try:
default_offset = int(request.GET["offset"])
except (KeyError, ValueError):
pass
model = view.table.get_model()
serializer_class = get_row_serializer_class(
model, RowSerializer, is_response=True
)
rows = get_rows_grouped_by_single_select_field(
table=view.table,
single_select_field=single_select_option_field,
option_settings=included_select_options,
default_limit=default_limit,
default_offset=default_offset,
model=model,
)
for key, value in rows.items():
rows[key]["results"] = serializer_class(value["results"], many=True).data
response = {"rows": rows}
if field_options:
view_type = view_type_registry.get_by_model(view)
context = {"fields": [o["field"] for o in model._field_objects.values()]}
serializer_class = view_type.get_field_options_serializer_class()
response.update(**serializer_class(view, context=context).data)
return Response(response)

View file

@ -9,6 +9,7 @@ class BaserowPremiumConfig(AppConfig):
from baserow.api.user.registries import user_data_registry
from baserow.contrib.database.export.registries import table_exporter_registry
from baserow.contrib.database.rows.registries import row_metadata_registry
from baserow.contrib.database.views.registries import view_type_registry
from baserow_premium.row_comments.row_metadata_types import (
RowCommentCountMetadataType,
@ -20,6 +21,7 @@ class BaserowPremiumConfig(AppConfig):
from .plugins import PremiumPlugin
from .export.exporter_types import JSONTableExporter, XMLTableExporter
from .views.view_types import KanbanViewType
plugin_registry.register(PremiumPlugin())
@ -30,6 +32,8 @@ class BaserowPremiumConfig(AppConfig):
user_data_registry.register(PremiumUserDataType())
view_type_registry.register(KanbanViewType())
# The signals must always be imported last because they use the registries
# which need to be filled first.
import baserow_premium.ws.signals # noqa: F403, F401

View file

@ -0,0 +1,102 @@
# Generated by Django 3.2.6 on 2021-10-27 16:21
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("database", "0040_formulafield_remove_field_by_id"),
("baserow_premium", "0002_licenses"),
]
operations = [
migrations.CreateModel(
name="KanbanView",
fields=[
(
"view_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="database.view",
),
),
],
options={
"db_table": "database_kanbanview",
},
bases=("database.view",),
),
migrations.CreateModel(
name="KanbanViewFieldOptions",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"hidden",
models.BooleanField(
default=True,
help_text="Whether or not the field should be hidden in the "
"card.",
),
),
(
"order",
models.SmallIntegerField(
default=32767,
help_text="The order that the field has in the form. Lower "
"value is first.",
),
),
(
"field",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="database.field"
),
),
(
"kanban_view",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="baserow_premium.kanbanview",
),
),
],
options={
"db_table": "database_kanbanviewfieldoptions",
"ordering": ("order", "field_id"),
},
),
migrations.AddField(
model_name="kanbanview",
name="field_options",
field=models.ManyToManyField(
through="baserow_premium.KanbanViewFieldOptions", to="database.Field"
),
),
migrations.AddField(
model_name="kanbanview",
name="single_select_field",
field=models.ForeignKey(
blank=True,
help_text="The single select field related to the options where rows "
"should be stacked by.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="kanban_view_single_select_field",
to="database.singleselectfield",
),
),
]

View file

@ -0,0 +1,11 @@
class KanbanViewHasNoSingleSelectField(Exception):
"""
Raised when the kanban does not have a single select option field and one is
required.
"""
class KanbanViewFieldDoesNotBelongToSameTable(Exception):
"""
Raised when the provided field does not belong to the same table as the kanban view.
"""

View file

@ -0,0 +1,117 @@
from typing import Dict, Union
from collections import defaultdict
from django.db.models import Q, Count
from baserow.contrib.database.table.models import Table, GeneratedTableModel
from baserow.contrib.database.fields.models import SingleSelectField
def get_rows_grouped_by_single_select_field(
table: Table,
single_select_field: SingleSelectField,
option_settings: Dict[str, Dict[str, int]] = None,
default_limit: int = 40,
default_offset: int = 0,
model: GeneratedTableModel = None,
) -> Dict[str, Dict[str, Union[int, list]]]:
"""
This method fetches the rows grouped by a single select field in a query
efficient manner. Optionally `limit` and `offset` settings can be provided per
option. If the option settings not provided, then rows for all the select options
will be fetched. If one or more options have been provided, then only the rows
for those will be fetched.
Example:
get_rows_grouped_by_single_select_field(
...
options_settings={
"1": {"limit": 10, "offset": 10},
"2": {"limit": 10, "offset": 20}
}
)
:param table: The table where to fetch the rows from.
:param single_select_field: The single select field where the rows must be
grouped by.
:param option_settings: Optionally, additional `limit` and `offset`
configurations per field option can be provided.
:param default_limit: The default limit that applies to all options if no
specific settings for that field have been provided.
:param default_offset: The default offset that applies to all options if no
specific settings for that field have been provided.
:param model: Additionally, an existing model can be provided so that it doesn't
have to be generated again.
:return: The fetched rows including the total count.
"""
if option_settings is None:
option_settings = {}
if model is None:
model = table.get_model()
base_queryset = model.objects.all().enhance_by_fields().order_by("order", "id")
all_filters = Q()
count_aggregates = {}
all_options = list(single_select_field.select_options.all())
all_option_ids = [option.id for option in all_options]
def get_id_and_string(option):
return (
option.id if option else None,
str(option.id) if option else "null",
)
for select_option in [None] + all_options:
option_id, option_string = get_id_and_string(select_option)
# If option settings have been provided, we only want to return rows for
# those options, otherwise we will include all options.
if len(option_settings) > 0 and option_string not in option_settings:
continue
option_setting = option_settings.get(option_string, {})
limit = option_setting.get("limit", default_limit)
offset = option_setting.get("offset", default_offset)
if option_id is None:
# Somehow the `Count` aggregate doesn't support an empty `__in` lookup.
# That's why we always add the `-1` value that never exists to make sure
# there is always a value in there.
filters = ~Q(
**{f"field_{single_select_field.id}_id__in": all_option_ids + [-1]}
)
else:
filters = Q(**{f"field_{single_select_field.id}_id": option_id})
# We don't want to execute a single query for each select option,
# so we create a subquery that finds the ids of the rows related to the
# option group. After the single query has been executed we can group the rows.
sub_queryset = base_queryset.filter(filters).values_list("id", flat=True)[
offset : offset + limit
]
all_filters |= Q(id__in=sub_queryset)
# Same goes for fetching the total count. We will construct a single query,
# that calculates to total amount of rows per option.
count_aggregates[option_string] = Count(
"pk",
filter=filters,
)
queryset = list(base_queryset.filter(all_filters))
counts = model.objects.aggregate(**count_aggregates)
rows = defaultdict(lambda: {"count": 0, "results": []})
for row in queryset:
option_id = getattr(row, f"field_{single_select_field.id}_id")
option_string = str(option_id) if option_id in all_option_ids else "null"
rows[option_string]["results"].append(row)
for key, value in counts.items():
rows[key]["count"] = value
return rows

View file

@ -0,0 +1,43 @@
from django.db import models
from baserow.contrib.database.fields.models import Field, SingleSelectField
from baserow.contrib.database.views.models import View
from baserow.contrib.database.mixins import ParentFieldTrashableModelMixin
class KanbanView(View):
field_options = models.ManyToManyField(Field, through="KanbanViewFieldOptions")
single_select_field = models.ForeignKey(
SingleSelectField,
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="kanban_view_single_select_field",
help_text="The single select field related to the options where rows should "
"be stacked by.",
)
class Meta:
db_table = "database_kanbanview"
class KanbanViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
kanban_view = models.ForeignKey(KanbanView, on_delete=models.CASCADE)
field = models.ForeignKey(Field, on_delete=models.CASCADE)
hidden = models.BooleanField(
default=True,
help_text="Whether or not the field should be hidden in the card.",
)
# The default value is the maximum value of the small integer field because a newly
# created field must always be last.
order = models.SmallIntegerField(
default=32767,
help_text="The order that the field has in the form. Lower value is first.",
)
class Meta:
db_table = "database_kanbanviewfieldoptions"
ordering = (
"order",
"field_id",
)

View file

@ -0,0 +1,140 @@
from django.urls import path, include
from rest_framework.serializers import PrimaryKeyRelatedField
from baserow.contrib.database.fields.models import SingleSelectField
from baserow.contrib.database.views.registries import ViewType
from baserow_premium.api.views.kanban.serializers import (
KanbanViewFieldOptionsSerializer,
)
from baserow_premium.api.views.kanban.errors import (
ERROR_KANBAN_VIEW_FIELD_DOES_NOT_BELONG_TO_SAME_TABLE,
)
from .models import KanbanView, KanbanViewFieldOptions
from .exceptions import KanbanViewFieldDoesNotBelongToSameTable
class KanbanViewType(ViewType):
type = "kanban"
model_class = KanbanView
field_options_model_class = KanbanViewFieldOptions
field_options_serializer_class = KanbanViewFieldOptionsSerializer
allowed_fields = ["single_select_field"]
serializer_field_names = ["single_select_field"]
serializer_field_overrides = {
"single_select_field": PrimaryKeyRelatedField(
queryset=SingleSelectField.objects.all(),
required=False,
default=None,
allow_null=True,
)
}
api_exceptions_map = {
KanbanViewFieldDoesNotBelongToSameTable: (
ERROR_KANBAN_VIEW_FIELD_DOES_NOT_BELONG_TO_SAME_TABLE
),
}
def get_api_urls(self):
from baserow_premium.api.views.kanban import urls as api_urls
return [
path("kanban/", include(api_urls, namespace=self.type)),
]
def prepare_values(self, values, table, user):
"""
Check if the provided single select option belongs to the same table.
"""
name = "single_select_field"
if name in values:
if isinstance(values[name], int):
values[name] = SingleSelectField.objects.get(pk=values[name])
if (
isinstance(values[name], SingleSelectField)
and values[name].table_id != table.id
):
raise KanbanViewFieldDoesNotBelongToSameTable(
"The provided single select field does not belong to the kanban "
"view's table."
)
return values
def export_serialized(self, kanban, files_zip, storage):
"""
Adds the serialized kanban view options to the exported dict.
"""
serialized = super().export_serialized(kanban, files_zip, storage)
serialized["single_select_field_id"] = kanban.single_select_field_id
serialized_field_options = []
for field_option in kanban.get_field_options():
serialized_field_options.append(
{
"id": field_option.id,
"field_id": field_option.field_id,
"hidden": field_option.hidden,
"order": field_option.order,
}
)
serialized["field_options"] = serialized_field_options
return serialized
def import_serialized(
self, table, serialized_values, id_mapping, files_zip, storage
):
"""
Imports the serialized kanban view field options.
"""
serialized_copy = serialized_values.copy()
serialized_copy["single_select_field_id"] = id_mapping["database_fields"][
serialized_copy.pop("single_select_field_id")
]
field_options = serialized_copy.pop("field_options")
kanban_view = super().import_serialized(
table, serialized_copy, id_mapping, files_zip, storage
)
if "database_kanban_view_field_options" not in id_mapping:
id_mapping["database_kanban_view_field_options"] = {}
for field_option in field_options:
field_option_copy = field_option.copy()
field_option_id = field_option_copy.pop("id")
field_option_copy["field_id"] = id_mapping["database_fields"][
field_option["field_id"]
]
field_option_object = KanbanViewFieldOptions.objects.create(
kanban_view=kanban_view, **field_option_copy
)
id_mapping["database_kanban_view_field_options"][
field_option_id
] = field_option_object.id
return kanban_view
def view_created(self, view):
"""
When a kanban view is created, we want to set the first three fields as visible.
"""
field_options = view.get_field_options(create_if_not_exists=True)
field_options.sort(key=lambda x: x.field_id)
ids_to_update = [
field_option.id
for index, field_option in enumerate(field_options)
if index < 3
]
if len(ids_to_update) > 0:
KanbanViewFieldOptions.objects.filter(id__in=ids_to_update).update(
hidden=False
)

View file

@ -0,0 +1,562 @@
import pytest
from django.shortcuts import reverse
from django.test.utils import override_settings
from rest_framework.status import (
HTTP_200_OK,
HTTP_400_BAD_REQUEST,
HTTP_402_PAYMENT_REQUIRED,
HTTP_404_NOT_FOUND,
)
from baserow_premium.views.models import KanbanView
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_list_without_valid_premium_license(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=False
)
kanban = premium_data_fixture.create_kanban_view(user=user)
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
assert response.status_code == HTTP_402_PAYMENT_REQUIRED
assert response.json()["error"] == "ERROR_NO_ACTIVE_PREMIUM_LICENSE"
# The kanban view should work if it's a template.
premium_data_fixture.create_template(group=kanban.table.database.group)
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
assert response.status_code == HTTP_200_OK
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_list_rows_invalid_parameters(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
email="test@test.nl",
password="password",
first_name="Test1",
has_active_premium_license=True,
)
kanban = premium_data_fixture.create_kanban_view(
user=user, single_select_field=None
)
kanban_2 = premium_data_fixture.create_kanban_view()
url = reverse("api:database:views:kanban:list", kwargs={"view_id": 0})
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_KANBAN_DOES_NOT_EXIST"
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban_2.id})
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_KANBAN_VIEW_HAS_NO_SINGLE_SELECT_FIELD"
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_list_rows_include_field_options(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
email="test@test.nl",
password="password",
first_name="Test1",
has_active_premium_license=True,
)
table = premium_data_fixture.create_database_table(user=user)
text_field = premium_data_fixture.create_text_field(table=table, primary=True)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
kanban = premium_data_fixture.create_kanban_view(
table=table, single_select_field=single_select_field
)
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?include=field_options", **{"HTTP_AUTHORIZATION": f"JWT {token}"}
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["field_options"]) == 2
assert response_json["field_options"][str(text_field.id)]["hidden"] is True
assert response_json["field_options"][str(text_field.id)]["order"] == 32767
assert response_json["field_options"][str(single_select_field.id)]["hidden"] is True
assert response_json["field_options"][str(single_select_field.id)]["order"] == 32767
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_list_all_rows(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=True
)
table = premium_data_fixture.create_database_table(user=user)
text_field = premium_data_fixture.create_text_field(table=table, primary=True)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
option_a = premium_data_fixture.create_select_option(
field=single_select_field, value="A", color="blue"
)
option_b = premium_data_fixture.create_select_option(
field=single_select_field, value="B", color="red"
)
kanban = premium_data_fixture.create_kanban_view(
table=table, single_select_field=single_select_field
)
model = table.get_model()
row_none = model.objects.create(
**{
f"field_{text_field.id}": "Row None",
f"field_{single_select_field.id}_id": None,
}
)
row_a1 = model.objects.create(
**{
f"field_{text_field.id}": "Row A1",
f"field_{single_select_field.id}_id": option_a.id,
}
)
row_a2 = model.objects.create(
**{
f"field_{text_field.id}": "Row A2",
f"field_{single_select_field.id}_id": option_a.id,
}
)
row_b1 = model.objects.create(
**{
f"field_{text_field.id}": "Row B1",
f"field_{single_select_field.id}_id": option_b.id,
}
)
row_b2 = model.objects.create(
**{
f"field_{text_field.id}": "Row B2",
f"field_{single_select_field.id}_id": option_b.id,
}
)
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["rows"]) == 3
assert response_json["rows"]["null"]["count"] == 1
assert len(response_json["rows"]["null"]["results"]) == 1
assert response_json["rows"]["null"]["results"][0] == {
"id": row_none.id,
"order": "1.00000000000000000000",
f"field_{text_field.id}": "Row None",
f"field_{single_select_field.id}": None,
}
assert response_json["rows"][str(option_a.id)]["count"] == 2
assert len(response_json["rows"][str(option_a.id)]["results"]) == 2
assert response_json["rows"][str(option_a.id)]["results"][0] == {
"id": row_a1.id,
"order": "1.00000000000000000000",
f"field_{text_field.id}": "Row A1",
f"field_{single_select_field.id}": {
"id": option_a.id,
"value": "A",
"color": "blue",
},
}
assert response_json["rows"][str(option_a.id)]["results"][1] == {
"id": row_a2.id,
"order": "1.00000000000000000000",
f"field_{text_field.id}": "Row A2",
f"field_{single_select_field.id}": {
"id": option_a.id,
"value": "A",
"color": "blue",
},
}
assert response_json["rows"][str(option_b.id)]["count"] == 2
assert len(response_json["rows"][str(option_b.id)]["results"]) == 2
assert response_json["rows"][str(option_b.id)]["results"][0] == {
"id": row_b1.id,
"order": "1.00000000000000000000",
f"field_{text_field.id}": "Row B1",
f"field_{single_select_field.id}": {
"id": option_b.id,
"value": "B",
"color": "red",
},
}
assert response_json["rows"][str(option_b.id)]["results"][1] == {
"id": row_b2.id,
"order": "1.00000000000000000000",
f"field_{text_field.id}": "Row B2",
f"field_{single_select_field.id}": {
"id": option_b.id,
"value": "B",
"color": "red",
},
}
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_list_with_specific_select_options(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=True
)
table = premium_data_fixture.create_database_table(user=user)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
option_a = premium_data_fixture.create_select_option(
field=single_select_field, value="A", color="blue"
)
kanban = premium_data_fixture.create_kanban_view(
table=table, single_select_field=single_select_field
)
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?select_option={option_a.id}",
**{"HTTP_AUTHORIZATION": f"JWT" f" {token}"},
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json) == 1
assert response_json["rows"][str(option_a.id)]["count"] == 0
assert len(response_json["rows"][str(option_a.id)]["results"]) == 0
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?select_option=null",
**{"HTTP_AUTHORIZATION": f"JWT" f" {token}"},
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json) == 1
assert response_json["rows"]["null"]["count"] == 0
assert len(response_json["rows"]["null"]["results"]) == 0
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?select_option={option_a.id}&select_option=null",
**{"HTTP_AUTHORIZATION": f"JWT" f" {token}"},
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["rows"]) == 2
assert response_json["rows"]["null"]["count"] == 0
assert len(response_json["rows"]["null"]["results"]) == 0
assert response_json["rows"][str(option_a.id)]["count"] == 0
assert len(response_json["rows"][str(option_a.id)]["results"]) == 0
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_list_all_rows_with_limit_and_offset(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=True
)
table = premium_data_fixture.create_database_table(user=user)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
option_a = premium_data_fixture.create_select_option(
field=single_select_field, value="A", color="blue"
)
kanban = premium_data_fixture.create_kanban_view(
table=table, single_select_field=single_select_field
)
model = table.get_model()
row_none1 = model.objects.create(
**{
f"field_{single_select_field.id}_id": None,
}
)
row_none2 = model.objects.create(
**{
f"field_{single_select_field.id}_id": None,
}
)
row_a1 = model.objects.create(
**{
f"field_{single_select_field.id}_id": option_a.id,
}
)
row_a2 = model.objects.create(
**{
f"field_{single_select_field.id}_id": option_a.id,
}
)
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?limit=1&offset=1", **{"HTTP_AUTHORIZATION": f"JWT {token}"}
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["rows"]) == 2
assert response_json["rows"]["null"]["count"] == 2
assert len(response_json["rows"]["null"]["results"]) == 1
assert response_json["rows"]["null"]["results"][0]["id"] == row_none2.id
assert response_json["rows"][str(option_a.id)]["count"] == 2
assert len(response_json["rows"][str(option_a.id)]["results"]) == 1
assert response_json["rows"][str(option_a.id)]["results"][0]["id"] == row_a2.id
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?select_option=null,1,1", **{"HTTP_AUTHORIZATION": f"JWT {token}"}
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json) == 1
assert response_json["rows"]["null"]["count"] == 2
assert len(response_json["rows"]["null"]["results"]) == 1
assert response_json["rows"]["null"]["results"][0]["id"] == row_none2.id
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?select_option={option_a.id},1,1",
**{"HTTP_AUTHORIZATION": f"JWT" f" {token}"},
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json) == 1
assert response_json["rows"][str(option_a.id)]["count"] == 2
assert len(response_json["rows"][str(option_a.id)]["results"]) == 1
assert response_json["rows"][str(option_a.id)]["results"][0]["id"] == row_a2.id
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?select_option={option_a.id},1,1&select_option=null,2,0",
**{"HTTP_AUTHORIZATION": f"JWT" f" {token}"},
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["rows"]) == 2
assert response_json["rows"]["null"]["count"] == 2
assert len(response_json["rows"]["null"]["results"]) == 2
assert response_json["rows"]["null"]["results"][0]["id"] == row_none1.id
assert response_json["rows"]["null"]["results"][1]["id"] == row_none2.id
assert response_json["rows"][str(option_a.id)]["count"] == 2
assert len(response_json["rows"][str(option_a.id)]["results"]) == 1
assert response_json["rows"][str(option_a.id)]["results"][0]["id"] == row_a2.id
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?select_option={option_a.id},2,0&select_option=null&limit=1&offset=1",
**{"HTTP_AUTHORIZATION": f"JWT" f" {token}"},
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["rows"]) == 2
assert response_json["rows"]["null"]["count"] == 2
assert len(response_json["rows"]["null"]["results"]) == 1
assert response_json["rows"]["null"]["results"][0]["id"] == row_none2.id
assert response_json["rows"][str(option_a.id)]["count"] == 2
assert len(response_json["rows"][str(option_a.id)]["results"]) == 2
assert response_json["rows"][str(option_a.id)]["results"][0]["id"] == row_a1.id
assert response_json["rows"][str(option_a.id)]["results"][1]["id"] == row_a2.id
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_list_all_invalid_select_option_parameter(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=True
)
table = premium_data_fixture.create_database_table(user=user)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
kanban = premium_data_fixture.create_kanban_view(
table=table, single_select_field=single_select_field
)
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
response = api_client.get(
f"{url}?select_option=null,a",
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_INVALID_SELECT_OPTION_PARAMETER"
response = api_client.get(
f"{url}?select_option=null,1,1&select_option=1,1,a",
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_INVALID_SELECT_OPTION_PARAMETER"
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_patch_kanban_view_field_options(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
email="test@test.nl",
password="password",
first_name="Test1",
has_active_premium_license=True,
)
table = premium_data_fixture.create_database_table(user=user)
text_field = premium_data_fixture.create_text_field(table=table)
kanban = premium_data_fixture.create_kanban_view(
table=table, single_select_field=None
)
url = reverse("api:database:views:field_options", kwargs={"view_id": kanban.id})
response = api_client.patch(
url,
{"field_options": {text_field.id: {"width": 300, "hidden": False}}},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["field_options"]) == 1
assert response_json["field_options"][str(text_field.id)]["hidden"] is False
assert response_json["field_options"][str(text_field.id)]["order"] == 32767
options = kanban.get_field_options()
assert len(options) == 1
assert options[0].field_id == text_field.id
assert options[0].hidden is False
assert options[0].order == 32767
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_create_kanban_view(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=True
)
table = premium_data_fixture.create_database_table(user=user)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
single_select_field_2 = premium_data_fixture.create_single_select_field()
response = api_client.post(
reverse("api:database:views:list", kwargs={"table_id": table.id}),
{
"name": "Test 1",
"type": "kanban",
"filter_type": "OR",
"filters_disabled": True,
"single_select_field": single_select_field_2.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_KANBAN_VIEW_FIELD_DOES_NOT_BELONG_TO_SAME_TABLE"
)
response = api_client.post(
reverse("api:database:views:list", kwargs={"table_id": table.id}),
{
"name": "Test 1",
"type": "kanban",
"filter_type": "OR",
"filters_disabled": True,
"single_select_field": None,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["name"] == "Test 1"
assert response_json["type"] == "kanban"
assert response_json["filter_type"] == "OR"
assert response_json["filters_disabled"] is True
assert response_json["single_select_field"] is None
kanban_view = KanbanView.objects.all().last()
assert kanban_view.id == response_json["id"]
assert kanban_view.single_select_field is None
response = api_client.post(
reverse("api:database:views:list", kwargs={"table_id": table.id}),
{
"name": "Test 2",
"type": "kanban",
"filter_type": "AND",
"filters_disabled": False,
"single_select_field": single_select_field.id,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["name"] == "Test 2"
assert response_json["type"] == "kanban"
assert response_json["filter_type"] == "AND"
assert response_json["filters_disabled"] is False
assert response_json["single_select_field"] == single_select_field.id
kanban_view = KanbanView.objects.all().last()
assert kanban_view.id == response_json["id"]
assert kanban_view.single_select_field_id == single_select_field.id
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_update_kanban_view(api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=True
)
table = premium_data_fixture.create_database_table(user=user)
kanban_view = premium_data_fixture.create_kanban_view(
table=table, single_select_field=None
)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
single_select_field_2 = premium_data_fixture.create_single_select_field()
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": kanban_view.id}),
{
"single_select_field": single_select_field_2.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_KANBAN_VIEW_FIELD_DOES_NOT_BELONG_TO_SAME_TABLE"
)
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": kanban_view.id}),
{
"single_select_field": single_select_field.id,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["single_select_field"] == single_select_field.id
kanban_view.refresh_from_db()
assert kanban_view.single_select_field_id == single_select_field.id
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": kanban_view.id}),
{
"single_select_field": None,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["single_select_field"] is None
kanban_view.refresh_from_db()
assert kanban_view.single_select_field is None

View file

@ -1,4 +1,7 @@
from baserow.contrib.database.fields.models import Field
from baserow_premium.license.models import License, LicenseUser
from baserow_premium.views.models import KanbanView, KanbanViewFieldOptions
VALID_ONE_SEAT_LICENSE = (
@ -63,3 +66,33 @@ class PremiumFixtures:
kwargs["license"] = self.create_premium_license()
return LicenseUser.objects.create(**kwargs)
def create_kanban_view(self, user=None, **kwargs):
if "table" not in kwargs:
kwargs["table"] = self.create_database_table(user=user)
if "name" not in kwargs:
kwargs["name"] = self.fake.name()
if "order" not in kwargs:
kwargs["order"] = 0
if "single_select_field" not in kwargs:
kwargs["single_select_field"] = self.create_single_select_field(
table=kwargs["table"],
)
kanban_view = KanbanView.objects.create(**kwargs)
self.create_kanban_view_field_options(kanban_view)
return kanban_view
def create_kanban_view_field_options(self, kanban_view, **kwargs):
return [
self.create_kanban_view_field_option(kanban_view, field, **kwargs)
for field in Field.objects.filter(table=kanban_view.table)
]
def create_kanban_view_field_option(self, kanban_view, field, **kwargs):
return KanbanViewFieldOptions.objects.create(
kanban_view=kanban_view, field=field, **kwargs
)

View file

@ -0,0 +1,227 @@
import pytest
from baserow_premium.views.handler import get_rows_grouped_by_single_select_field
@pytest.mark.django_db
def test_get_rows_grouped_by_single_select_field(
premium_data_fixture, django_assert_num_queries
):
table = premium_data_fixture.create_database_table()
text_field = premium_data_fixture.create_text_field(table=table, primary=True)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
option_a = premium_data_fixture.create_select_option(
field=single_select_field, value="A", color="blue"
)
option_b = premium_data_fixture.create_select_option(
field=single_select_field, value="B", color="red"
)
model = table.get_model()
row_none1 = model.objects.create(
**{
f"field_{text_field.id}": "Row None 1",
f"field_{single_select_field.id}_id": None,
}
)
row_none2 = model.objects.create(
**{
f"field_{text_field.id}": "Row None 2",
f"field_{single_select_field.id}_id": None,
}
)
row_a1 = model.objects.create(
**{
f"field_{text_field.id}": "Row A1",
f"field_{single_select_field.id}_id": option_a.id,
}
)
row_a2 = model.objects.create(
**{
f"field_{text_field.id}": "Row A2",
f"field_{single_select_field.id}_id": option_a.id,
}
)
row_b1 = model.objects.create(
**{
f"field_{text_field.id}": "Row B1",
f"field_{single_select_field.id}_id": option_b.id,
}
)
row_b2 = model.objects.create(
**{
f"field_{text_field.id}": "Row B2",
f"field_{single_select_field.id}_id": option_b.id,
}
)
# The amount of queries including
with django_assert_num_queries(4):
rows = get_rows_grouped_by_single_select_field(
table, single_select_field, model=model
)
assert len(rows) == 3
assert rows["null"]["count"] == 2
assert len(rows["null"]["results"]) == 2
assert rows["null"]["results"][0].id == row_none1.id
assert rows["null"]["results"][1].id == row_none2.id
assert rows[str(option_a.id)]["count"] == 2
assert len(rows[str(option_a.id)]["results"]) == 2
assert rows[str(option_a.id)]["results"][0].id == row_a1.id
assert rows[str(option_a.id)]["results"][1].id == row_a2.id
assert rows[str(option_b.id)]["count"] == 2
assert len(rows[str(option_b.id)]["results"]) == 2
assert rows[str(option_b.id)]["results"][0].id == row_b1.id
assert rows[str(option_b.id)]["results"][1].id == row_b2.id
rows = get_rows_grouped_by_single_select_field(
table, single_select_field, default_limit=1
)
assert len(rows) == 3
assert rows["null"]["count"] == 2
assert len(rows["null"]["results"]) == 1
assert rows["null"]["results"][0].id == row_none1.id
assert rows[str(option_a.id)]["count"] == 2
assert len(rows[str(option_a.id)]["results"]) == 1
assert rows[str(option_a.id)]["results"][0].id == row_a1.id
assert rows[str(option_b.id)]["count"] == 2
assert len(rows[str(option_b.id)]["results"]) == 1
assert rows[str(option_b.id)]["results"][0].id == row_b1.id
rows = get_rows_grouped_by_single_select_field(
table, single_select_field, default_limit=1, default_offset=1
)
assert len(rows) == 3
assert rows["null"]["count"] == 2
assert len(rows["null"]["results"]) == 1
assert rows["null"]["results"][0].id == row_none2.id
assert rows[str(option_a.id)]["count"] == 2
assert len(rows[str(option_a.id)]["results"]) == 1
assert rows[str(option_a.id)]["results"][0].id == row_a2.id
assert rows[str(option_b.id)]["count"] == 2
assert len(rows[str(option_b.id)]["results"]) == 1
assert rows[str(option_b.id)]["results"][0].id == row_b2.id
rows = get_rows_grouped_by_single_select_field(
table,
single_select_field,
option_settings={"null": {"limit": 1, "offset": 1}},
)
assert len(rows) == 1
assert rows["null"]["count"] == 2
assert len(rows["null"]["results"]) == 1
assert rows["null"]["results"][0].id == row_none2.id
rows = get_rows_grouped_by_single_select_field(
table,
single_select_field,
option_settings={
str(option_a.id): {"limit": 1, "offset": 0},
str(option_b.id): {"limit": 1, "offset": 1},
},
)
assert len(rows) == 2
assert rows[str(option_a.id)]["count"] == 2
assert len(rows[str(option_a.id)]["results"]) == 1
assert rows[str(option_a.id)]["results"][0].id == row_a1.id
assert rows[str(option_b.id)]["count"] == 2
assert len(rows[str(option_b.id)]["results"]) == 1
assert rows[str(option_b.id)]["results"][0].id == row_b2.id
rows = get_rows_grouped_by_single_select_field(
table,
single_select_field,
option_settings={
str(option_a.id): {"limit": 10, "offset": 10},
},
)
assert len(rows) == 1
assert rows[str(option_a.id)]["count"] == 2
assert len(rows[str(option_a.id)]["results"]) == 0
@pytest.mark.django_db
def test_get_rows_grouped_by_single_select_field_not_existing_options_are_null(
premium_data_fixture,
):
table = premium_data_fixture.create_database_table()
text_field = premium_data_fixture.create_text_field(table=table, primary=True)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
option_a = premium_data_fixture.create_select_option(
field=single_select_field, value="A", color="blue"
)
option_b = premium_data_fixture.create_select_option(
field=single_select_field, value="B", color="red"
)
model = table.get_model()
row_1 = model.objects.create(
**{
f"field_{text_field.id}": "Row 1",
f"field_{single_select_field.id}_id": None,
}
)
row_2 = model.objects.create(
**{
f"field_{text_field.id}": "Row 2",
f"field_{single_select_field.id}_id": option_a.id,
}
)
row_3 = model.objects.create(
**{
f"field_{text_field.id}": "Row 3",
f"field_{single_select_field.id}_id": option_b.id,
}
)
option_b.delete()
rows = get_rows_grouped_by_single_select_field(
table, single_select_field, model=model
)
assert len(rows) == 2
assert rows["null"]["count"] == 2
assert len(rows["null"]["results"]) == 2
assert rows["null"]["results"][0].id == row_1.id
assert rows["null"]["results"][1].id == row_3.id
assert rows[str(option_a.id)]["count"] == 1
assert len(rows[str(option_a.id)]["results"]) == 1
assert rows[str(option_a.id)]["results"][0].id == row_2.id
option_a.delete()
rows = get_rows_grouped_by_single_select_field(
table, single_select_field, model=model
)
assert len(rows) == 1
assert rows["null"]["count"] == 3
assert len(rows["null"]["results"]) == 3
assert rows["null"]["results"][0].id == row_1.id
assert rows["null"]["results"][1].id == row_2.id
assert rows["null"]["results"][2].id == row_3.id
@pytest.mark.django_db
def test_get_rows_grouped_by_single_select_field_with_empty_table(
premium_data_fixture,
):
table = premium_data_fixture.create_database_table()
single_select_field = premium_data_fixture.create_single_select_field(table=table)
rows = get_rows_grouped_by_single_select_field(table, single_select_field)
assert len(rows) == 1
assert rows["null"]["count"] == 0
assert len(rows["null"]["results"]) == 0

View file

@ -0,0 +1,148 @@
import pytest
from io import BytesIO
from zipfile import ZipFile, ZIP_DEFLATED
from django.core.files.storage import FileSystemStorage
from baserow.contrib.database.views.registries import view_type_registry
from baserow.contrib.database.views.handler import ViewHandler
from baserow_premium.views.exceptions import KanbanViewFieldDoesNotBelongToSameTable
from baserow_premium.views.models import KanbanViewFieldOptions
@pytest.mark.django_db
def test_field_of_same_table_is_provided(premium_data_fixture):
user = premium_data_fixture.create_user()
table = premium_data_fixture.create_database_table(user=user)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
single_select_field_2 = premium_data_fixture.create_single_select_field()
view_handler = ViewHandler()
with pytest.raises(KanbanViewFieldDoesNotBelongToSameTable):
view_handler.create_view(
user=user,
table=table,
type_name="kanban",
single_select_field=single_select_field_2,
)
with pytest.raises(KanbanViewFieldDoesNotBelongToSameTable):
view_handler.create_view(
user=user,
table=table,
type_name="kanban",
single_select_field=single_select_field_2.id,
)
kanban_view = view_handler.create_view(
user=user,
table=table,
type_name="kanban",
single_select_field=single_select_field,
)
with pytest.raises(KanbanViewFieldDoesNotBelongToSameTable):
view_handler.update_view(
user=user,
view=kanban_view,
single_select_field=single_select_field_2,
)
with pytest.raises(KanbanViewFieldDoesNotBelongToSameTable):
view_handler.update_view(
user=user,
view=kanban_view,
single_select_field=single_select_field_2.id,
)
view_handler.update_view(
user=user,
view=kanban_view,
single_select_field=None,
)
@pytest.mark.django_db
def test_import_export_kanban_view(premium_data_fixture, tmpdir):
user = premium_data_fixture.create_user()
storage = FileSystemStorage(location=str(tmpdir), base_url="http://localhost")
table = premium_data_fixture.create_database_table(user=user)
kanban_view = premium_data_fixture.create_kanban_view(
table=table,
single_select_field=None,
)
single_select_field = premium_data_fixture.create_single_select_field(table=table)
field_option = premium_data_fixture.create_kanban_view_field_option(
kanban_view=kanban_view, field=single_select_field, hidden=True, order=1
)
kanban_view.single_select_field = single_select_field
files_buffer = BytesIO()
kanban_field_type = view_type_registry.get("kanban")
with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip:
serialized = kanban_field_type.export_serialized(
kanban_view, files_zip=files_zip, storage=storage
)
assert serialized["id"] == kanban_view.id
assert serialized["type"] == "kanban"
assert serialized["name"] == kanban_view.name
assert serialized["order"] == 0
assert serialized["single_select_field_id"] == single_select_field.id
assert len(serialized["field_options"]) == 1
assert serialized["field_options"][0]["id"] == field_option.id
assert serialized["field_options"][0]["field_id"] == field_option.field_id
assert serialized["field_options"][0]["hidden"] is True
assert serialized["field_options"][0]["order"] == 1
imported_single_select_field = premium_data_fixture.create_single_select_field(
table=table
)
id_mapping = {
"database_fields": {single_select_field.id: imported_single_select_field.id}
}
with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip:
imported_kanban_view = kanban_field_type.import_serialized(
kanban_view.table, serialized, id_mapping, files_zip, storage
)
assert kanban_view.id != imported_kanban_view.id
assert kanban_view.name == imported_kanban_view.name
assert kanban_view.order == imported_kanban_view.order
assert (
kanban_view.single_select_field_id != imported_kanban_view.single_select_field
)
imported_field_options = imported_kanban_view.get_field_options()
assert len(imported_field_options) == 1
imported_field_option = imported_field_options[0]
assert field_option.id != imported_field_option.id
assert imported_single_select_field.id == imported_field_option.field_id
assert field_option.hidden == imported_field_option.hidden
assert field_option.order == imported_field_option.order
@pytest.mark.django_db
def test_newly_created_view(premium_data_fixture):
user = premium_data_fixture.create_user(has_active_premium_license=True)
table = premium_data_fixture.create_database_table(user=user)
premium_data_fixture.create_text_field(table=table, primary=True)
premium_data_fixture.create_text_field(table=table)
premium_data_fixture.create_text_field(table=table)
premium_data_fixture.create_text_field(table=table)
handler = ViewHandler()
handler.create_view(user, table=table, type_name="kanban")
all_field_options = (
KanbanViewFieldOptions.objects.all()
.order_by("field_id")
.values_list("hidden", flat=True)
)
assert list(all_field_options) == [False, False, False, True]

View file

@ -3,8 +3,8 @@ import { DatabaseApplicationType } from '@baserow/modules/database/applicationTy
import GridViewRowExpandButtonWithCommentCount from '@baserow_premium/components/row_comments/GridViewRowExpandButtonWithCommentCount'
export class PremiumDatabaseApplicationType extends DatabaseApplicationType {
getRowEditModalRightSidebarComponent() {
return RowCommentsSidebar
getRowEditModalRightSidebarComponent(readOnly) {
return readOnly ? null : RowCommentsSidebar
}
getRowExpandButtonComponent() {

View file

@ -6,3 +6,4 @@
@import 'expandable_textarea';
@import 'licenses';
@import 'license_detail';
@import 'views/kanban';

View file

@ -0,0 +1,220 @@
.kanban-view {
overflow-x: scroll;
@include absolute(0);
}
.kanban-view__stacks {
display: flex;
flex-wrap: nowrap;
@include absolute(0, auto, 0, 0);
}
.kanban-view__stack-wrapper {
padding: 20px 0 20px 20px;
&:last-child {
padding-right: 20px;
}
}
.kanban-view__collapsed-stack-wrapper {
position: relative;
width: 40px;
height: 100%;
}
.kanban-view__collapsed-stack {
background-color: rgba(0, 0, 0, 0.05);
border-radius: 12px;
white-space: nowrap;
display: flex;
align-items: center;
height: 40px;
padding: 0 20px;
transform: rotate(-90deg) translateX(-100%);
transform-origin: left top 0;
@include absolute(0, auto, auto, 0);
&:hover {
text-decoration: none;
background-color: rgba(0, 0, 0, 0.075);
}
}
.kanban-view__stack {
background-color: rgba(0, 0, 0, 0.05);
border-radius: 12px;
max-height: 100%;
width: 320px;
display: flex;
flex-direction: column;
}
.kanban-view__stack-head {
display: flex;
align-items: center;
width: 100%;
padding: 20px 20px 10px 20px;
}
.kanban-view__drag {
flex: 0 0 12px;
height: 16px;
cursor: grab;
background-image: radial-gradient($color-neutral-300 40%, transparent 40%);
background-size: 4px 4px;
background-repeat: repeat;
margin-right: 10px;
}
.kanban-view__uncategorized {
@extend %ellipsis;
min-width: 0;
width: 100%;
margin-right: 10px;
line-height: 120%;
font-size: 13px;
font-weight: 600;
}
.kanban-view__option-wrapper {
min-width: 0;
width: 100%;
margin-right: 10px;
}
.kanban-view__option {
@extend %ellipsis;
@include select-option-style(inline-block, false);
}
.kanban-view__count {
flex: 0 0;
background-color: $color-neutral-200;
border-radius: 3px;
color: $color-neutral-700;
padding: 0 8px;
margin-right: 10px;
@include fixed-height(22px, 12px);
}
.kanban-view__options {
flex: 0 0;
font-size: 12px;
line-height: 22px;
color: $color-primary-900;
&:hover {
text-decoration: none;
color: $color-neutral-500;
}
}
.kanban-view__stack-cards {
position: relative;
max-height: 100%;
padding: 0 20px;
}
.kanban-view__stack-card {
position: absolute;
left: 20px;
right: 20px;
top: 0;
margin-bottom: 10px;
&:last-child {
margin-bottom: 3px;
}
&:not(.kanban-view__stack-card--disabled) * {
pointer-events: none;
user-select: none;
}
&.kanban-view__stack-card--dragging-copy {
pointer-events: none;
left: 0;
right: 0;
opacity: 0.9;
transform: none !important;
}
&.kanban-view__stack-card--dragging {
background: transparent;
border: none;
box-shadow: none;
* {
visibility: hidden;
}
}
.kanban-view__stack--dragging & {
// Must be kept in sync with the timeout of KanbanViewStack.vue::moved
transition-duration: 0.1s;
transition-timing-function: ease-out;
transition-property: transform;
transition-delay: 0s;
}
&:not(.kanban-view__stack-card--disabled):not(.kanban-view__stack-card--dragging):hover {
cursor: pointer;
box-shadow: 0 1px 3px 0 rgba($black, 0.32);
}
}
.kanban-view__stack-foot {
padding: 10px 20px 20px 20px;
}
.kanban-view__stack-new-button {
width: 100%;
}
.kanban-view__add-stack {
margin: 20px;
background-color: $white;
border: solid 1px $color-neutral-400;
border-radius: 100%;
color: $color-primary-900;
@include center-text(28px, 12px);
line-height: 26px;
&:hover {
text-decoration: none;
background-color: $color-neutral-50;
}
}
.kanban-view__stacked-by-page {
margin: 20px auto;
width: 320px;
border: solid 1px $color-neutral-200;
border-radius: 6px;
background-color: $white;
}
.kanban-view__stacked-by {
padding: 20px;
}
.kanban-view__stacked-by-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 10px;
}
.kanban-view__stacked-by-description {
font-size: 13px;
line-height: 18px;
margin-bottom: 16px;
}

View file

@ -0,0 +1,263 @@
<template>
<div v-if="singleSelectField === null" class="kanban-view__stacked-by-page">
<KanbanViewStackedBy
:table="table"
:view="view"
:fields="fields"
:primary="primary"
:read-only="readOnly"
:store-prefix="storePrefix"
:include-field-options-on-refresh="true"
@refresh="$emit('refresh', $event)"
></KanbanViewStackedBy>
</div>
<div
v-else
ref="kanban"
v-auto-scroll="{
orientation: 'horizontal',
enabled: () => draggingRow !== null,
speed: 6,
padding: 12,
}"
class="kanban-view"
>
<div class="kanban-view__stacks">
<KanbanViewStack
:table="table"
:view="view"
:card-fields="cardFields"
:fields="fields"
:primary="primary"
:read-only="readOnly"
:store-prefix="storePrefix"
@create-row="openCreateRowModal"
@edit-row="$refs.rowEditModal.show($event.id)"
@refresh="$emit('refresh', $event)"
></KanbanViewStack>
<KanbanViewStack
v-for="option in existingSelectOption"
:key="option.id"
:option="option"
:table="table"
:view="view"
:card-fields="cardFields"
:fields="fields"
:primary="primary"
:read-only="readOnly"
:store-prefix="storePrefix"
@create-row="openCreateRowModal"
@edit-row="$refs.rowEditModal.show($event.id)"
@refresh="$emit('refresh', $event)"
></KanbanViewStack>
<a
v-if="!readOnly"
ref="addOptionContextLink"
class="kanban-view__add-stack"
@click="$refs.addOptionContext.toggle($refs.addOptionContextLink)"
>
<i class="fas fa-plus"></i>
</a>
<KanbanViewCreateStackContext
ref="addOptionContext"
:fields="fields"
:primary="primary"
:store-prefix="storePrefix"
></KanbanViewCreateStackContext>
</div>
<RowCreateModal
ref="rowCreateModal"
:table="table"
:fields="fields"
:primary="primary"
@created="createRow"
@field-updated="$emit('refresh', $event)"
@field-deleted="$emit('refresh')"
></RowCreateModal>
<RowEditModal
ref="rowEditModal"
:table="table"
:fields="fields"
:primary="primary"
:rows="allRows"
:read-only="false"
@update="updateValue"
@field-updated="$emit('refresh', $event)"
@field-deleted="$emit('refresh')"
></RowEditModal>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { clone } from '@baserow/modules/core/utils/object'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes'
import RowCreateModal from '@baserow/modules/database/components/row/RowCreateModal'
import RowEditModal from '@baserow/modules/database/components/row/RowEditModal'
import kanbanViewHelper from '@baserow_premium/mixins/kanbanViewHelper'
import KanbanViewStack from '@baserow_premium/components/views/kanban/KanbanViewStack'
import KanbanViewStackedBy from '@baserow_premium/components/views/kanban/KanbanViewStackedBy'
import KanbanViewCreateStackContext from '@baserow_premium/components/views/kanban/KanbanViewCreateStackContext'
export default {
name: 'KanbanView',
components: {
RowCreateModal,
RowEditModal,
KanbanViewCreateStackContext,
KanbanViewStackedBy,
KanbanViewStack,
},
mixins: [kanbanViewHelper],
props: {
database: {
type: Object,
required: true,
},
table: {
type: Object,
required: true,
},
view: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
primary: {
type: Object,
required: true,
},
readOnly: {
type: Boolean,
required: true,
},
},
data() {
return {
row: {},
}
},
computed: {
/**
* Returns the visible field objects in the right order.
*/
cardFields() {
return [this.primary]
.concat(this.fields)
.filter((field) => {
const exists = Object.prototype.hasOwnProperty.call(
this.fieldOptions,
field.id
)
return !exists || (exists && !this.fieldOptions[field.id].hidden)
})
.sort((a, b) => {
const orderA = this.fieldOptions[a.id]
? this.fieldOptions[a.id].order
: maxPossibleOrderValue
const orderB = this.fieldOptions[b.id]
? this.fieldOptions[b.id].order
: maxPossibleOrderValue
// First by order.
if (orderA > orderB) {
return 1
} else if (orderA < orderB) {
return -1
}
// Then by id.
if (a.id < b.id) {
return -1
} else if (a.id > b.id) {
return 1
} else {
return 0
}
})
},
/**
* Returns the single select field object that the kanban view uses to group the
* cards in stacks.
*/
singleSelectField() {
const allFields = [this.primary].concat(this.fields)
for (let i = 0; i < allFields.length; i++) {
if (allFields[i].id === this.singleSelectFieldId) {
return allFields[i]
}
}
return null
},
existingSelectOption() {
return this.singleSelectField.select_options.filter((option) => {
return this.$store.getters[
this.$options.propsData.storePrefix + 'view/kanban/stackExists'
](option.id)
})
},
},
beforeCreate() {
this.$options.computed = {
...(this.$options.computed || {}),
...mapGetters({
singleSelectFieldId:
this.$options.propsData.storePrefix +
'view/kanban/getSingleSelectFieldId',
allRows: this.$options.propsData.storePrefix + 'view/kanban/getAllRows',
draggingRow:
this.$options.propsData.storePrefix + 'view/kanban/getDraggingRow',
}),
}
},
methods: {
openCreateRowModal(event) {
const defaults = {}
if (event.option !== null) {
const name = `field_${this.singleSelectField.id}`
defaults[name] = clone(event.option)
}
this.$refs.rowCreateModal.show(defaults)
},
async createRow({ row, callback }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/kanban/createNewRow',
{
table: this.table,
fields: this.fields,
primary: this.primary,
values: row,
}
)
callback()
} catch (error) {
callback(error)
}
},
async updateValue({ field, row, value, oldValue }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/kanban/updateRowValue',
{
table: this.table,
view: this.view,
fields: this.fields,
primary: this.primary,
row,
field,
value,
oldValue,
}
)
} catch (error) {
notifyIf(error, 'field')
}
},
},
}
</script>

View file

@ -0,0 +1,69 @@
<template>
<Context>
<KanbanViewOptionForm ref="form" @submitted="submit">
<div class="context__form-actions">
<button
class="button"
:class="{ 'button--loading': loading }"
:disabled="loading"
>
{{ $t('action.create') }}
</button>
</div>
</KanbanViewOptionForm>
</Context>
</template>
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import context from '@baserow/modules/core/mixins/context'
import KanbanViewOptionForm from '@baserow_premium/components/views/kanban/KanbanViewOptionForm'
export default {
name: 'KanbanViewCreateStackContext',
components: { KanbanViewOptionForm },
mixins: [context],
props: {
fields: {
type: Array,
required: true,
},
primary: {
type: Object,
required: true,
},
storePrefix: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
}
},
methods: {
async submit(values) {
this.loading = true
try {
await this.$store.dispatch(
this.storePrefix + 'view/kanban/createStack',
{
fields: this.fields,
primary: this.primary,
color: values.color,
value: values.value,
}
)
this.$refs.form.reset()
this.hide()
} catch (error) {
notifyIf(error, 'field')
}
this.loading = false
},
},
}
</script>

View file

@ -0,0 +1,170 @@
<template>
<ul v-if="!tableLoading" class="header__filter header__filter--full-width">
<li class="header__filter-item">
<a
ref="stackedContextLink"
class="header__filter-link"
@click="
$refs.stackedContext.toggle(
$refs.stackedContextLink,
'bottom',
'left',
4
)
"
>
<i class="header__filter-icon fas fa-chevron-circle-down"></i>
<span class="header__filter-name">
<template v-if="view.single_select_field === null">Stack by</template
><template v-else>Stacked by {{ stackedByFieldName }}</template></span
>
</a>
<Context ref="stackedContext">
<KanbanViewStackedBy
:table="table"
:view="view"
:fields="fields"
:primary="primary"
:read-only="readOnly"
:store-prefix="storePrefix"
@refresh="$emit('refresh', $event)"
></KanbanViewStackedBy>
</Context>
</li>
<li v-if="singleSelectFieldId !== -1" class="header__filter-item">
<a
ref="customizeContextLink"
class="header__filter-link"
@click="
$refs.customizeContext.toggle(
$refs.customizeContextLink,
'bottom',
'left',
4
)
"
>
<i class="header__filter-icon fas fa-cog"></i>
<span class="header__filter-name">Customize cards</span>
</a>
<ViewFieldsContext
ref="customizeContext"
:fields="allFields"
:read-only="readOnly"
:field-options="fieldOptions"
@update-all-field-options="updateAllFieldOptions"
@update-field-options-of-field="updateFieldOptionsOfField"
@update-order="orderFieldOptions"
></ViewFieldsContext>
</li>
</ul>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
import ViewFieldsContext from '@baserow/modules/database/components/view/ViewFieldsContext'
import KanbanViewStackedBy from '@baserow_premium/components/views/kanban/KanbanViewStackedBy'
import kanbanViewHelper from '@baserow_premium/mixins/kanbanViewHelper'
export default {
name: 'KanbanViewHeader',
components: { KanbanViewStackedBy, ViewFieldsContext },
mixins: [kanbanViewHelper],
props: {
database: {
type: Object,
required: true,
},
table: {
type: Object,
required: true,
},
view: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
primary: {
type: Object,
required: true,
},
readOnly: {
type: Boolean,
required: true,
},
},
computed: {
stackedByFieldName() {
const allFields = [this.primary].concat(this.fields)
for (let i = 0; i < allFields.length; i++) {
if (allFields[i].id === this.view.single_select_field) {
return allFields[i].name
}
}
return ''
},
allFields() {
return [this.primary].concat(this.fields)
},
...mapState({
tableLoading: (state) => state.table.loading,
}),
},
beforeCreate() {
this.$options.computed = {
...(this.$options.computed || {}),
...mapGetters({
singleSelectFieldId:
this.$options.propsData.storePrefix +
'view/kanban/getSingleSelectFieldId',
}),
}
},
methods: {
async updateAllFieldOptions({ newFieldOptions, oldFieldOptions }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/kanban/updateAllFieldOptions',
{
newFieldOptions,
oldFieldOptions,
}
)
} catch (error) {
notifyIf(error, 'view')
}
},
async updateFieldOptionsOfField({ field, values, oldValues }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/kanban/updateFieldOptionsOfField',
{
field,
values,
oldValues,
}
)
} catch (error) {
notifyIf(error, 'view')
}
},
async orderFieldOptions({ order }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/kanban/updateFieldOptionsOrder',
{
order,
}
)
} catch (error) {
notifyIf(error, 'view')
}
},
},
}
</script>

View file

@ -0,0 +1,70 @@
<template>
<form class="context__form" @submit.prevent="submit">
<div class="control">
<label class="control__label">Select option</label>
<div class="control__elements">
<div class="select-options">
<div class="select-options__item">
<a
ref="colorSelect"
:class="
'select-options__color' + ' background-color--' + values.color
"
@click="
$refs.colorContext.toggle(
$refs.colorSelect,
'bottom',
'left',
4
)
"
>
<i class="fas fa-caret-down"></i>
</a>
<input
v-model="values.value"
class="input select-options__value"
:class="{ 'input--error': $v.values.value.$error }"
@blur="$v.values.value.$touch()"
/>
</div>
</div>
</div>
<div v-if="$v.values.value.$error" class="error">
{{ $t('error.requiredField') }}
</div>
</div>
<slot></slot>
<ColorSelectContext
ref="colorContext"
@selected="values.color = $event"
></ColorSelectContext>
</form>
</template>
<script>
import { required } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form'
import { colors } from '@baserow/modules/core/utils/colors'
import ColorSelectContext from '@baserow/modules/core/components/ColorSelectContext'
export default {
name: 'KanbanViewOptionForm',
components: { ColorSelectContext },
mixins: [form],
data() {
return {
allowedValues: ['color', 'value'],
values: {
color: colors[Math.floor(Math.random() * colors.length)],
value: '',
},
}
},
validations: {
values: {
value: { required },
},
},
}
</script>

View file

@ -0,0 +1,534 @@
<template>
<div
ref="wrapper"
v-auto-scroll="{
enabled: () => draggingRow !== null,
speed: 3,
padding: 24,
scrollElement: () => $refs.scroll.$el,
}"
class="kanban-view__stack-wrapper"
@mouseleave.stop="wrapperMouseLeave"
>
<div
class="kanban-view__stack"
:class="{ 'kanban-view__stack--dragging': draggingRow !== null }"
@mousemove="stackMoveOver($event, stack, id)"
>
<div class="kanban-view__stack-head">
<div v-if="option === null" class="kanban-view__uncategorized">
Uncategorized
</div>
<template v-else>
<!--<a v-if="!readOnly" href="#" class="kanban-view__drag"></a>-->
<div class="kanban-view__option-wrapper">
<div
class="kanban-view__option"
:class="'background-color--' + option.color"
>
{{ option.value }}
</div>
</div>
</template>
<div class="kanban-view__count">
{{ stack.count }}
</div>
<a
v-if="!readOnly"
ref="editContextLink"
class="kanban-view__options"
@click="
$refs.editContext.toggle(
$refs.editContextLink,
'bottom',
'right',
-2
)
"
>
<i class="fas fa-ellipsis-h"></i>
</a>
<KanbanViewStackContext
ref="editContext"
:option="option"
:fields="fields"
:primary="primary"
:store-prefix="storePrefix"
@create-row="$emit('create-row', { option })"
@refresh="$emit('refresh', $event)"
></KanbanViewStackContext>
</div>
<InfiniteScroll
ref="scroll"
:max-count="stack.count"
:current-count="stack.results.length"
:loading="loading"
:render-end="false"
class="kanban-view__stack-cards"
@load-next-page="fetch('scroll')"
>
<template #default>
<div
:style="{ 'min-height': cardHeight * stack.results.length + 'px' }"
>
<RowCard
v-for="slot in buffer"
v-show="slot.position != -1"
:key="'card-' + slot.id"
:fields="cardFields"
:row="slot.row"
:style="{
transform: `translateY(${
slot.position * cardHeight + bufferTop
}px)`,
}"
class="kanban-view__stack-card"
:class="{
'kanban-view__stack-card--dragging': slot.row._.dragging,
'kanban-view__stack-card--disabled': readOnly,
}"
@mousedown="cardDown($event, slot.row)"
@mousemove="cardMoveOver($event, slot.row)"
></RowCard>
</div>
<div v-if="error" class="margin-top-2">
<a @click="fetch('click')">
Try again <i class="fas fa-refresh"></i>
</a>
</div>
</template>
</InfiniteScroll>
<div class="kanban-view__stack-foot">
<a
class="button button--ghost kanban-view__stack-new-button"
:disabled="draggingRow !== null || readOnly"
@click="!readOnly && $emit('create-row', { option })"
>
<i class="fas fa-plus"></i>
New
</a>
</div>
</div>
<!--
<div class="kanban-view__stack-wrapper">
<div class="kanban-view__collapsed-stack-wrapper">
<a class="kanban-view__collapsed-stack">
<div class="kanban-view__count">10 records</div>
<div class="kanban-view__option-wrapper margin-right-0">
<div class="kanban-view__option background-color--green">
Idea
</div>
</div>
</a>
</div>
</div>
-->
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
import kanbanViewHelper from '@baserow_premium/mixins/kanbanViewHelper'
import RowCard from '@baserow/modules/database/components/card/RowCard'
import InfiniteScroll from '@baserow/modules/core/components/helpers/InfiniteScroll'
import { populateRow } from '@baserow_premium/store/view/kanban'
import KanbanViewStackContext from '@baserow_premium/components/views/kanban/KanbanViewStackContext'
export default {
name: 'KanbanViewStack',
components: { InfiniteScroll, RowCard, KanbanViewStackContext },
mixins: [kanbanViewHelper],
props: {
option: {
validator: (prop) => typeof prop === 'object' || prop === null,
required: false,
default: null,
},
table: {
type: Object,
required: true,
},
view: {
type: Object,
required: true,
},
cardFields: {
type: Array,
required: true,
},
fields: {
type: Array,
required: true,
},
primary: {
type: Object,
required: true,
},
readOnly: {
type: Boolean,
required: true,
},
},
data() {
return {
error: false,
loading: false,
buffer: [],
bufferTop: 0,
scrollHeight: 0,
scrollTop: 0,
// Contains an HTML DOM element copy of the card that's being dragged.
copyElement: null,
// The row object that's currently being down
downCardRow: null,
// The initial horizontal position absolute client position of the card after
// mousedown.
downCardClientX: 0,
// The initial vertical position absolute client position of the card after
// mousedown.
downCardClientY: 0,
// The autoscroll timeout that keeps keeps calling the autoScrollLoop method to
// initiate the autoscroll effect when dragging a card.
autoScrollTimeout: null,
}
},
computed: {
/**
* In order for the virtual scrolling to work, we need to know what the height of
* the card is to correctly position it.
*/
cardHeight() {
// margin-bottom of card.scss.card__field, that we don't have to compensate for
// if there aren't any fields in the card.
const fieldMarginBottom = this.cardFields.length === 0 ? 0 : 10
return (
// Some of these values must be kep in sync with card.scss
this.cardFields.reduce((accumulator, field) => {
const fieldType = this.$registry.get('field', field._.type.type)
return (
accumulator +
fieldType.getCardValueHeight(field) +
6 + // margin-bottom of card.scss.card__field-name
14 + // line-height of card.scss.card__field-name
10 // margin-bottom of card.scss.card__field
)
}, 0) +
16 + // padding-top of card.scss.card
16 - // padding-bottom of card.scss.card
fieldMarginBottom +
10 // margin-bottom of kanban.scss.kanban-view__stack-card
)
},
/**
* Figure out what the stack id that's used in the store is. The representation is
* slightly different there.
*/
id() {
return this.option === null ? 'null' : this.option.id.toString()
},
/**
* Using option id received via the properties, we can get the related stack from
* the store.
*/
stack() {
return this.$store.getters[this.storePrefix + 'view/kanban/getStack'](
this.id
)
},
},
watch: {
cardHeight() {
this.$nextTick(() => {
this.updateBuffer()
})
},
'stack.results'() {
this.$nextTick(() => {
this.updateBuffer()
})
},
},
mounted() {
this.updateBuffer()
this.$el.resizeEvent = () => {
this.updateBuffer()
}
this.$el.scrollEvent = () => {
this.updateBuffer()
}
window.addEventListener('resize', this.$el.resizeEvent)
this.$refs.scroll.$el.addEventListener('scroll', this.$el.scrollEvent)
},
beforeDestroy() {
window.removeEventListener('resize', this.$el.resizeEvent)
this.$refs.scroll.$el.removeEventListener('scroll', this.$el.scrollEvent)
},
beforeCreate() {
this.$options.computed = {
...(this.$options.computed || {}),
...mapGetters({
draggingRow:
this.$options.propsData.storePrefix + 'view/kanban/getDraggingRow',
draggingOriginalStackId:
this.$options.propsData.storePrefix +
'view/kanban/getDraggingOriginalStackId',
}),
}
},
methods: {
/**
* Called when a user presses the left mouse on a card. This method will prepare
* the dragging if the user moves the mouse a bit. Otherwise, if the mouse is
* release without moving, the edit modal is opened.
*/
cardDown(event, row) {
// If it isn't a left click.
if (event.button !== 0 || this.readOnly) {
return
}
event.preventDefault()
const rect = event.target.getBoundingClientRect()
this.downCardRow = row
this.downCardClientX = event.clientX
this.downCardClientY = event.clientY
this.downCardTop = event.clientY - rect.top
this.downCardLeft = event.clientX - rect.left
this.copyElement = document.createElement('div')
this.copyElement.innerHTML = event.target.outerHTML
this.copyElement.style = `position: absolute; left: 0; top: 0; width: ${rect.width}px; z-index: 10;`
this.copyElement.firstChild.classList.add(
'kanban-view__stack-card--dragging-copy'
)
this.$el.keydownEvent = (event) => {
if (event.keyCode === 27) {
if (this.draggingRow !== null) {
this.$store.dispatch(
this.storePrefix + 'view/kanban/cancelRowDrag',
{
row: this.draggingRow,
originalStackId: this.draggingOriginalStackId,
}
)
}
this.cardCancel(event)
}
}
document.body.addEventListener('keydown', this.$el.keydownEvent)
this.$el.mouseMoveEvent = (event) => this.cardMove(event)
window.addEventListener('mousemove', this.$el.mouseMoveEvent)
this.$el.mouseUpEvent = (event) => this.cardUp(event)
window.addEventListener('mouseup', this.$el.mouseUpEvent)
this.cardMove(event)
},
async cardMove(event) {
if (this.draggingRow === null) {
if (
Math.abs(event.clientX - this.downCardClientX) > 3 ||
Math.abs(event.clientY - this.downCardClientY) > 3
) {
document.body.appendChild(this.copyElement)
await this.$store.dispatch(
this.storePrefix + 'view/kanban/startRowDrag',
{
row: this.downCardRow,
}
)
}
}
this.copyElement.style.top = event.clientY - this.downCardTop + 'px'
this.copyElement.style.left = event.clientX - this.downCardLeft + 'px'
},
async cardUp() {
if (this.draggingRow !== null) {
this.cardCancel()
try {
await this.$store.dispatch(
this.storePrefix + 'view/kanban/stopRowDrag',
{
table: this.table,
fields: this.fields,
primary: this.primary,
}
)
} catch (error) {
notifyIf(error)
}
} else {
this.$emit('edit-row', this.downCardRow)
this.cardCancel()
}
},
cardCancel() {
this.downCardRow = null
this.copyElement.remove()
document.body.removeEventListener('keydown', this.$el.keydownEvent)
window.removeEventListener('mousemove', this.$el.mouseMoveEvent)
window.removeEventListener('mouseup', this.$el.mouseUpEvent)
},
async cardMoveOver(event, row) {
if (
this.draggingRow === null ||
this.draggingRow.id === row.id ||
!!event.target.transitioning
) {
return
}
const rect = event.target.getBoundingClientRect()
const top = event.clientY - rect.top
const half = rect.height / 2
const before = top <= half
const moved = await this.$store.dispatch(
this.storePrefix + 'view/kanban/forceMoveRowBefore',
{
row: this.draggingRow,
targetRow: row,
targetBefore: before,
}
)
if (moved) {
this.moved(event)
}
},
/**
* When dragging a row over an empty stack, we want to move that row into it.
* Normally the row is only moved when it's being dragged over an existing card,
* but it must also be possible drag a row into an empty stack that doesn't have
* any cards.
*/
async stackMoveOver(event, stack, id) {
if (
this.draggingRow === null ||
stack.results.length > 0 ||
!!event.target.transitioning
) {
return
}
const moved = await this.$store.dispatch(
this.storePrefix + 'view/kanban/forceMoveRowTo',
{
row: this.draggingRow,
targetStackId: id,
targetIndex: 0,
}
)
if (moved) {
this.moved(event)
}
},
/**
* After a row has been moved, we need to temporarily need to set the transition
* state to true. While it's true, it can't be moved to another position to avoid
* strange transition effects of other cards.
*/
moved(event) {
event.target.transitioning = true
setTimeout(
() => {
event.target.transitioning = false
},
// Must be kept in sync with the transition-duration of
// kanban.scss.kanban-view__stack--dragging
100
)
},
wrapperMouseLeave() {
clearTimeout(this.autoScrollTimeout)
this.autoScrollTimeout = null
},
updateBuffer() {
const el = this.$refs.scroll.$el
const cardHeight = this.cardHeight
const containerHeight = el.clientHeight
const scrollTop = el.scrollTop
const min = Math.ceil(containerHeight / cardHeight) + 2
const rows = this.stack.results.slice(
Math.floor(scrollTop / cardHeight),
Math.ceil((scrollTop + containerHeight) / cardHeight)
)
this.bufferTop =
rows.length > 0
? this.stack.results.findIndex((row) => row.id === rows[0].id) *
cardHeight
: 0
// First fill up the buffer with the minimum amount of slots.
for (let i = this.buffer.length; i < min; i++) {
this.buffer.push({
id: i,
row: populateRow({ id: -1 }),
position: -1,
})
}
// Remove not needed slots.
this.buffer = this.buffer.slice(0, min)
// Check which rows are should not be displayed anymore and clear that slow
// in the buffer.
this.buffer.forEach((slot) => {
const exists = rows.findIndex((row) => row.id === slot.row.id) >= 0
if (!exists) {
slot.row = populateRow({ id: -1 })
slot.position = -1
}
})
// Then check which rows should have which position.
rows.forEach((row, position) => {
// Check if the row is already in the buffer
const index = this.buffer.findIndex((slot) => slot.row.id === row.id)
if (index >= 0) {
// If the row already exists in the buffer, then only update the position.
this.buffer[index].position = position
} else {
// If the row does not yet exists in the buffer, then we can find the first
// empty slot and place it there.
const emptyIndex = this.buffer.findIndex((slot) => slot.row.id === -1)
this.buffer[emptyIndex].row = row
this.buffer[emptyIndex].position = position
}
})
},
/**
* Called when an additional set of rows must be fetched for this stack. This
* typically happens when the user reaches the end of the card list.
*/
async fetch(type) {
if (this.error && type === 'scroll') {
return
}
this.error = false
this.loading = true
try {
await this.$store.dispatch(this.storePrefix + 'view/kanban/fetchMore', {
selectOptionId: this.id,
})
} catch (error) {
this.error = true
notifyIf(error)
}
this.loading = false
},
},
}
</script>

View file

@ -0,0 +1,120 @@
<template>
<Context>
<ul class="context__menu">
<li>
<a @click=";[$emit('create-row'), hide()]">
<i class="context__menu-icon fas fa-fw fa-plus"></i>
Create row
</a>
</li>
<li v-if="option !== null">
<a
ref="updateContextLink"
@click="$refs.updateContext.toggle($refs.updateContextLink)"
>
<i class="context__menu-icon fas fa-fw fa-pen"></i>
Edit stack
</a>
<KanbanViewUpdateStackContext
ref="updateContext"
:option="option"
:fields="fields"
:primary="primary"
:store-prefix="storePrefix"
@saved="hide()"
></KanbanViewUpdateStackContext>
</li>
<li v-if="option !== null">
<a @click="$refs.deleteModal.show()">
<i class="context__menu-icon fas fa-fw fa-trash-alt"></i>
Delete stack
</a>
</li>
</ul>
<Modal v-if="option !== null" ref="deleteModal">
<h2 class="box__title">Delete {{ option.value }}</h2>
<Error :error="error"></Error>
<div>
<p>
Are you sure that you want to delete stack {{ option.value }}?
Deleting the stack results in deleting the select option of the single
select field, which might result into data loss because row values are
going to be set to empty.
</p>
<div class="actions">
<div class="align-right">
<a
class="button button--large button--error"
:class="{ 'button--loading': loading }"
:disabled="loading"
@click="deleteStack()"
>
Delete {{ option.value }}
</a>
</div>
</div>
</div>
</Modal>
</Context>
</template>
<script>
import context from '@baserow/modules/core/mixins/context'
import error from '@baserow/modules/core/mixins/error'
import KanbanViewUpdateStackContext from '@baserow_premium/components/views/kanban/KanbanViewUpdateStackContext'
export default {
name: 'KanbanViewStackContext',
components: { KanbanViewUpdateStackContext },
mixins: [context, error],
props: {
option: {
validator: (prop) => typeof prop === 'object' || prop === null,
required: true,
},
fields: {
type: Array,
required: true,
},
primary: {
type: Object,
required: true,
},
storePrefix: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
}
},
methods: {
async deleteStack() {
this.loading = true
try {
const doUpdate = await this.$store.dispatch(
this.storePrefix + 'view/kanban/deleteStack',
{
optionId: this.option.id,
fields: this.fields,
primary: this.primary,
deferredFieldUpdate: true,
}
)
await this.$emit('refresh', {
callback: () => {
doUpdate()
this.loading = false
},
})
} catch (error) {
this.handleError(error)
this.loading = false
}
},
},
}
</script>

View file

@ -0,0 +1,107 @@
<template>
<div class="kanban-view__stacked-by">
<div class="kanban-view__stacked-by-title">Group field</div>
<div class="kanban-view__stacked-by-description">
Which single select field should the cards be stacked by?
</div>
<Radio
v-for="field in singleSelectFields"
:key="field.id"
v-model="singleSelectField"
:value="field.id"
:loading="loading && field.id === singleSelectField"
:disabled="loading || readOnly"
@input="update"
>{{ field.name }}</Radio
>
<div v-if="!readOnly" class="margin-top-2">
<a
ref="createFieldContextLink"
class="margin-right-auto"
@click="$refs.createFieldContext.toggle($refs.createFieldContextLink)"
>
<i class="fas fa-plus"></i>
add single select field
</a>
<CreateFieldContext
ref="createFieldContext"
:table="table"
:forced-type="forcedFieldType"
></CreateFieldContext>
</div>
</div>
</template>
<script>
import kanbanViewHelper from '@baserow_premium/mixins/kanbanViewHelper'
import { SingleSelectFieldType } from '@baserow/modules/database/fieldTypes'
import CreateFieldContext from '@baserow/modules/database/components/field/CreateFieldContext'
export default {
name: 'KanbanViewStackedBy',
components: { CreateFieldContext },
mixins: [kanbanViewHelper],
props: {
table: {
type: Object,
required: true,
},
view: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
primary: {
type: Object,
required: true,
},
readOnly: {
type: Boolean,
required: true,
},
includeFieldOptionsOnRefresh: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
loading: false,
singleSelectField: this.view.single_select_field,
}
},
computed: {
singleSelectFields() {
const allFields = [this.primary].concat(this.fields)
const singleSelectFieldType = SingleSelectFieldType.getType()
return allFields.filter((field) => field.type === singleSelectFieldType)
},
forcedFieldType() {
return SingleSelectFieldType.getType()
},
},
watch: {
'view.single_select_field'(value) {
this.singleSelectField = value
},
},
methods: {
async update(value) {
this.loading = true
await this.updateKanban({
single_select_field: value,
})
this.$emit('refresh', {
callback: () => {
this.loading = false
},
includeFieldOptions: this.includeFieldOptionsOnRefresh,
})
},
},
}
</script>

View file

@ -0,0 +1,77 @@
<template>
<Context>
<KanbanViewOptionForm
ref="form"
:default-values="option"
@submitted="submit"
>
<div class="context__form-actions">
<button
class="button"
:class="{ 'button--loading': loading }"
:disabled="loading"
>
{{ $t('action.change') }}
</button>
</div>
</KanbanViewOptionForm>
</Context>
</template>
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import context from '@baserow/modules/core/mixins/context'
import KanbanViewOptionForm from '@baserow_premium/components/views/kanban/KanbanViewOptionForm'
export default {
name: 'KanbanViewUpdateStackContext',
components: { KanbanViewOptionForm },
mixins: [context],
props: {
option: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
primary: {
type: Object,
required: true,
},
storePrefix: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
}
},
methods: {
async submit(values) {
this.loading = true
try {
await this.$store.dispatch(
this.storePrefix + 'view/kanban/updateStack',
{
optionId: this.option.id,
fields: this.fields,
primary: this.primary,
values,
}
)
this.$emit('saved')
this.hide()
} catch (error) {
notifyIf(error, 'field')
}
this.loading = false
},
},
}
</script>

View file

@ -0,0 +1,38 @@
import { mapGetters } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
props: {
storePrefix: {
type: String,
required: true,
},
},
methods: {
async updateKanban(values) {
const view = this.view
this.$store.dispatch('view/setItemLoading', { view, value: true })
try {
await this.$store.dispatch('view/update', {
view,
values,
})
} catch (error) {
notifyIf(error, 'view')
}
this.$store.dispatch('view/setItemLoading', { view, value: false })
},
},
beforeCreate() {
this.$options.computed = {
...(this.$options.computed || {}),
...mapGetters({
fieldOptions:
this.$options.propsData.storePrefix +
'view/kanban/getAllFieldOptions',
}),
}
},
}

View file

@ -10,8 +10,10 @@ import {
LicensesAdminType,
} from '@baserow_premium/adminTypes'
import rowCommentsStore from '@baserow_premium/store/row_comments'
import kanbanStore from '@baserow_premium/store/view/kanban'
import { PremiumDatabaseApplicationType } from '@baserow_premium/applicationTypes'
import { registerRealtimeEvents } from '@baserow_premium/realtime'
import { KanbanViewType } from '@baserow_premium/viewTypes'
export default (context) => {
const { store, app } = context
@ -23,6 +25,8 @@ export default (context) => {
)
store.registerModule('row_comments', rowCommentsStore)
store.registerModule('page/view/kanban', kanbanStore)
store.registerModule('template/view/kanban', kanbanStore)
app.$registry.register('plugin', new PremiumPlugin(context))
app.$registry.register('admin', new DashboardType(context))
@ -31,6 +35,7 @@ export default (context) => {
app.$registry.register('admin', new LicensesAdminType(context))
app.$registry.register('exporter', new JSONTableExporter(context))
app.$registry.register('exporter', new XMLTableExporter(context))
app.$registry.register('view', new KanbanViewType(context))
registerRealtimeEvents(app.$realtime)

View file

@ -0,0 +1,47 @@
export default (client) => {
return {
fetchRows({
kanbanId,
limit = 100,
offset = null,
cancelToken = null,
includeFieldOptions = false,
selectOptions = [],
}) {
const include = []
const params = new URLSearchParams()
params.append('limit', limit)
if (offset !== null) {
params.append('offset', offset)
}
if (includeFieldOptions) {
include.push('field_options')
}
if (include.length > 0) {
params.append('include', include.join(','))
}
selectOptions.forEach((selectOption) => {
let value = selectOption.id.toString()
if (Object.prototype.hasOwnProperty.call(selectOption, 'limit')) {
value += `,${selectOption.limit}`
if (Object.prototype.hasOwnProperty.call(selectOption, 'offset')) {
value += `,${selectOption.offset}`
}
}
params.append('select_option', value)
})
const config = { params }
if (cancelToken !== null) {
config.cancelToken = cancelToken
}
return client.get(`/database/views/kanban/${kanbanId}/`, config)
},
}
}

View file

@ -0,0 +1,914 @@
import Vue from 'vue'
import _ from 'lodash'
import { clone } from '@baserow/modules/core/utils/object'
import ViewService from '@baserow/modules/database/services/view'
import KanbanService from '@baserow_premium/services/views/kanban'
import { getRowSortFunction } from '@baserow/modules/database/utils/view'
import RowService from '@baserow/modules/database/services/row'
import FieldService from '@baserow/modules/database/services/field'
import { SingleSelectFieldType } from '@baserow/modules/database/fieldTypes'
export function populateRow(row) {
row._ = {
dragging: false,
}
return row
}
export function populateStack(stack) {
Object.assign(stack, {
loading: false,
})
stack.results.forEach((row) => {
populateRow(row)
})
return stack
}
export const state = () => ({
lastKanbanId: -1,
singleSelectFieldId: -1,
stacks: {},
fieldOptions: {},
bufferRequestSize: 24,
draggingRow: null,
draggingOriginalStackId: null,
draggingOriginalBefore: null,
})
export const mutations = {
RESET(state) {
state.lastKanbanId = -1
state.singleSelectFieldId = -1
state.stacks = {}
state.fieldOptions = {}
},
SET_LAST_KANBAN_ID(state, kanbanId) {
state.lastKanbanId = kanbanId
},
SET_SINGLE_SELECT_FIELD_ID(state, singleSelectFieldId) {
state.singleSelectFieldId = singleSelectFieldId
},
REPLACE_ALL_STACKS(state, stacks) {
state.stacks = stacks
},
ADD_ROWS_TO_STACK(state, { selectOptionId, count, rows }) {
if (count) {
state.stacks[selectOptionId].count = count
}
state.stacks[selectOptionId].results.push(...rows)
},
REPLACE_ALL_FIELD_OPTIONS(state, fieldOptions) {
state.fieldOptions = fieldOptions
},
UPDATE_ALL_FIELD_OPTIONS(state, fieldOptions) {
state.fieldOptions = _.merge({}, state.fieldOptions, fieldOptions)
},
UPDATE_FIELD_OPTIONS_OF_FIELD(state, { fieldId, values }) {
if (Object.prototype.hasOwnProperty.call(state.fieldOptions, fieldId)) {
Object.assign(state.fieldOptions[fieldId], values)
} else {
state.fieldOptions = Object.assign({}, state.fieldOptions, {
[fieldId]: values,
})
}
},
DELETE_FIELD_OPTIONS(state, fieldId) {
if (Object.prototype.hasOwnProperty.call(state.fieldOptions, fieldId)) {
delete state.fieldOptions[fieldId]
}
},
ADD_STACK(state, { id, stack }) {
Vue.set(state.stacks, id.toString(), stack)
},
START_ROW_DRAG(state, { row, currentStackId, currentBefore }) {
row._.dragging = true
state.draggingRow = row
state.draggingOriginalStackId = currentStackId
state.draggingOriginalBefore = currentBefore
},
STOP_ROW_DRAG(state, { row }) {
row._.dragging = false
state.draggingRow = null
state.draggingOriginalStackId = null
state.draggingOriginalBefore = null
},
CREATE_ROW(state, { row, stackId, index }) {
state.stacks[stackId].results.splice(index, 0, row)
},
DELETE_ROW(state, { stackId, index }) {
state.stacks[stackId].results.splice(index, 1)
},
INCREASE_COUNT(state, { stackId }) {
state.stacks[stackId].count++
},
DECREASE_COUNT(state, { stackId }) {
state.stacks[stackId].count--
},
UPDATE_ROW(state, { row, values }) {
Object.keys(state.stacks).forEach((stack) => {
const rows = state.stacks[stack].results
const index = rows.findIndex((item) => item.id === row.id)
if (index !== -1) {
const existingRowState = rows[index]
Object.assign(existingRowState, values)
}
})
},
UPDATE_VALUE_OF_ALL_ROWS_IN_STACK(state, { fieldId, stackId, values }) {
const name = `field_${fieldId}`
state.stacks[stackId].results.forEach((row) => {
Object.assign(row[name], values)
})
},
MOVE_ROW(
state,
{ currentStackId, currentIndex, targetStackId, targetIndex }
) {
state.stacks[targetStackId].results.splice(
targetIndex,
0,
state.stacks[currentStackId].results.splice(currentIndex, 1)[0]
)
},
}
export const actions = {
/**
* This method is typically called when the kanban view loads, but when it doesn't
* yet have a single select option field. This will make sure that the old state
* of another kanban view will be reset.
*/
reset({ commit }) {
commit('RESET')
},
/**
* Fetches an initial set of rows and adds that data to the store.
*/
async fetchInitial(
{ dispatch, commit, getters },
{ kanbanId, singleSelectFieldId, includeFieldOptions = true }
) {
const { data } = await KanbanService(this.$client).fetchRows({
kanbanId,
limit: getters.getBufferRequestSize,
offset: 0,
includeFieldOptions,
selectOptions: [],
})
Object.keys(data.rows).forEach((key) => {
populateStack(data.rows[key])
})
commit('SET_LAST_KANBAN_ID', kanbanId)
commit('SET_SINGLE_SELECT_FIELD_ID', singleSelectFieldId)
commit('REPLACE_ALL_STACKS', data.rows)
if (includeFieldOptions) {
commit('REPLACE_ALL_FIELD_OPTIONS', data.field_options)
}
},
/**
* This action is called when the users scrolls to the end of the stack. Because
* we don't fetch all the rows, the next set will be fetched when the user reaches
* the end.
*/
async fetchMore({ dispatch, commit, getters }, { selectOptionId }) {
const stack = getters.getStack(selectOptionId)
const { data } = await KanbanService(this.$client).fetchRows({
kanbanId: getters.getLastKanbanId,
limit: getters.getBufferRequestSize,
offset: 0,
includeFieldOptions: false,
selectOptions: [
{
id: selectOptionId,
limit: getters.getBufferRequestSize,
offset: stack.results.length,
},
],
})
const count = data.rows[selectOptionId].count
const rows = data.rows[selectOptionId].results
rows.forEach((row) => {
populateRow(row)
})
commit('ADD_ROWS_TO_STACK', { selectOptionId, count, rows })
},
/**
* Updates the field options of a given field in the store. So no API request to
* the backend is made.
*/
setFieldOptionsOfField({ commit }, { field, values }) {
commit('UPDATE_FIELD_OPTIONS_OF_FIELD', {
fieldId: field.id,
values,
})
},
/**
* Replaces all field options with new values and also makes an API request to the
* backend with the changed values. If the request fails the action is reverted.
*/
async updateAllFieldOptions(
{ dispatch, getters },
{ kanban, newFieldOptions, oldFieldOptions }
) {
const kanbanId = getters.getLastKanbanId
dispatch('forceUpdateAllFieldOptions', newFieldOptions)
const updateValues = { field_options: newFieldOptions }
try {
await ViewService(this.$client).updateFieldOptions({
viewId: kanbanId,
values: updateValues,
})
} catch (error) {
dispatch('forceUpdateAllFieldOptions', oldFieldOptions)
throw error
}
},
/**
* Forcefully updates all field options without making a call to the backend.
*/
forceUpdateAllFieldOptions({ commit }, fieldOptions) {
commit('UPDATE_ALL_FIELD_OPTIONS', fieldOptions)
},
/**
* Deletes the field options of the provided field id if they exist.
*/
forceDeleteFieldOptions({ commit }, fieldId) {
commit('DELETE_FIELD_OPTIONS', fieldId)
},
/**
* Updates the order of all the available field options. The provided order parameter
* should be an array containing the field ids in the correct order.
*/
async updateFieldOptionsOrder({ commit, getters, dispatch }, { order }) {
const oldFieldOptions = clone(getters.getAllFieldOptions)
const newFieldOptions = clone(getters.getAllFieldOptions)
// Update the order of the field options that have not been provided in the order.
// They will get a position that places them after the provided field ids.
let i = 0
Object.keys(newFieldOptions).forEach((fieldId) => {
if (!order.includes(parseInt(fieldId))) {
newFieldOptions[fieldId].order = order.length + i
i++
}
})
// Update create the field options and set the correct order value.
order.forEach((fieldId, index) => {
const id = fieldId.toString()
if (Object.prototype.hasOwnProperty.call(newFieldOptions, id)) {
newFieldOptions[fieldId.toString()].order = index
}
})
return await dispatch('updateAllFieldOptions', {
oldFieldOptions,
newFieldOptions,
})
},
/**
* Updates the field options of a specific field.
*/
async updateFieldOptionsOfField(
{ commit, getters },
{ kanban, field, values }
) {
const kanbanId = getters.getLastKanbanId
const oldValues = clone(getters.getAllFieldOptions[field.id])
commit('UPDATE_FIELD_OPTIONS_OF_FIELD', {
fieldId: field.id,
values,
})
const updateValues = { field_options: {} }
updateValues.field_options[field.id] = values
try {
await ViewService(this.$client).updateFieldOptions({
viewId: kanbanId,
values: updateValues,
})
} catch (error) {
commit('UPDATE_FIELD_OPTIONS_OF_FIELD', {
fieldId: field.id,
values: oldValues,
})
throw error
}
},
/**
* Creates a new row and adds it to the state if needed.
*/
async createNewRow(
{ dispatch, commit, getters },
{ table, fields, primary, values }
) {
// First prepare an object that we can send to the
const allFields = [primary].concat(fields)
const preparedValues = {}
allFields.forEach((field) => {
const name = `field_${field.id}`
const fieldType = this.$registry.get('field', field._.type.type)
if (fieldType.isReadOnly) {
return
}
preparedValues[name] = Object.prototype.hasOwnProperty.call(values, name)
? (preparedValues[name] = fieldType.prepareValueForUpdate(
field,
values[name]
))
: fieldType.getEmptyValue(field)
})
const { data } = await RowService(this.$client).create(
table.id,
preparedValues
)
return await dispatch('createdNewRow', { values: data, fields, primary })
},
/**
* Can be called when a new row has been created. This action will make sure that
* the state is updated accordingly. If the newly created position is within the
* current buffer (`stack.results`), then it will be added there, otherwise, just
* the count is increased.
*
* @param values The values of the newly created row.
* @param row Can be provided when the row already existed within the state.
* In that case, the `_` data will be preserved. Can be useful when
* a row has been updated while being dragged.
*/
createdNewRow({ commit, getters }, { values, fields, primary }) {
const row = clone(values)
populateRow(row)
const singleSelectFieldId = getters.getSingleSelectFieldId
const option = row[`field_${singleSelectFieldId}`]
const stackId = option !== null ? option.id : 'null'
const stack = getters.getStack(stackId)
const sortedRows = clone(stack.results)
sortedRows.push(row)
sortedRows.sort(getRowSortFunction(this.$registry, [], fields, primary))
const index = sortedRows.findIndex((r) => r.id === row.id)
const isLast = index === sortedRows.length - 1
// Because we don't fetch all the rows from the backend, we can't know for sure
// whether or not the row is being added at the right position. Therefore, if
// it's last, we just not add it to the store and wait for the user to fetch the
// next page.
if (!isLast || stack.results.length === stack.count) {
commit('CREATE_ROW', { row, stackId, index })
}
// We always need to increase the count whether row has been added to the store
// or not because the count is for all the rows and not just the ones in the store.
commit('INCREASE_COUNT', { stackId })
},
/**
* Can be called when a row in the table has been deleted. This action will make
* sure that the state is updated accordingly.
*/
deletedExistingRow({ commit, getters }, { row }) {
const singleSelectFieldId = getters.getSingleSelectFieldId
const option = row[`field_${singleSelectFieldId}`]
const stackId = option !== null ? option.id : 'null'
const current = getters.findStackIdAndIndex(row.id)
if (current !== undefined) {
const currentStackId = current[0]
const currentIndex = current[1]
const currentRow = current[2]
commit('DELETE_ROW', { stackId: currentStackId, index: currentIndex })
commit('DECREASE_COUNT', { stackId: currentStackId })
return currentRow
} else {
commit('DECREASE_COUNT', { stackId })
}
return null
},
/**
* Can be called when a row in the table has been updated. This action will make sure
* that the state is updated accordingly. If the single select field value has
* changed, the row will be moved to the right stack. If the position has changed,
* it will be moved to the right position.
*/
updatedExistingRow(
{ dispatch, getters, commit },
{ row, values, fields, primary }
) {
const singleSelectFieldId = getters.getSingleSelectFieldId
const fieldName = `field_${singleSelectFieldId}`
// First, we virtually need to figure out if the row was in the old stack.
const oldRow = populateRow(clone(row))
const oldOption = oldRow[fieldName]
const oldStackId = oldOption !== null ? oldOption.id : 'null'
const oldStackResults = clone(getters.getStack(oldStackId).results)
const oldExistingIndex = oldStackResults.findIndex(
(r) => r.id === oldRow.id
)
const oldExists = oldExistingIndex > -1
// Second, we need to figure out if the row should be visible in the new stack.
const newRow = Object.assign(populateRow(clone(row)), values)
const newOption = newRow[fieldName]
const newStackId = newOption !== null ? newOption.id : 'null'
const newStack = getters.getStack(newStackId)
const newStackResults = clone(newStack.results)
const newRowCurrentIndex = newStackResults.findIndex(
(r) => r.id === newRow.id
)
let newStackCount = newStack.count
if (newRowCurrentIndex > -1) {
newStackResults.splice(newRowCurrentIndex, 1)
newStackCount--
}
newStackResults.push(newRow)
newStackCount++
newStackResults.sort(
getRowSortFunction(this.$registry, [], fields, primary)
)
const newIndex = newStackResults.findIndex((r) => r.id === newRow.id)
const newIsLast = newIndex === newStackResults.length - 1
const newExists = !newIsLast || newStackResults.length === newStackCount
commit('UPDATE_ROW', { row, values })
if (oldExists && newExists) {
commit('MOVE_ROW', {
currentStackId: oldStackId,
currentIndex: oldExistingIndex,
targetStackId: newStackId,
targetIndex: newIndex,
})
} else if (oldExists && !newExists) {
commit('DELETE_ROW', { stackId: oldStackId, index: oldExistingIndex })
} else if (!oldExists && newExists) {
commit('CREATE_ROW', {
row: newRow,
stackId: newStackId,
index: newIndex,
})
}
commit('DECREASE_COUNT', { stackId: oldStackId })
commit('INCREASE_COUNT', { stackId: newStackId })
},
/**
* The dragging of rows to other stacks and position basically consists of three+
* steps. First is calling this action which brings the rows into dragging state
* and stores what the current stack and and index was. A row in dragging state is
* basically an invisible placeholder card that can be moved to other positions
* using the available actions. When the row has been dragged to the right
* position, the `stopRowDrag` action can be called to finalize it.
*/
startRowDrag({ commit, getters }, { row }) {
const current = getters.findStackIdAndIndex(row.id)
const currentStackId = current[0]
const currentIndex = current[1]
const rows = getters.getStack(currentStackId).results
const currentBefore = rows[currentIndex + 1] || null
commit('START_ROW_DRAG', {
row,
currentStackId,
currentBefore,
})
},
/**
* This action removes the dragging state of a row, will figure out which values
* need to updated and will make a call to the backend. If something goes wrong,
* the row is moved back to the original stack and position.
*/
async stopRowDrag({ dispatch, commit, getters }, { table, fields, primary }) {
const row = getters.getDraggingRow
if (row === null) {
return
}
// First we need to figure out what the current position of the row is and how
// that should be communicated to the backend later. The backend expects another
// row id where it is placed before or null if it's placed in the end.
const originalStackId = getters.getDraggingOriginalStackId
const originalBefore = getters.getDraggingOriginalBefore
const current = getters.findStackIdAndIndex(row.id)
const currentStackId = current[0]
const currentIndex = current[1]
const rows = getters.getStack(currentStackId).results
const before = rows[currentIndex + 1] || null
// We need to have the single select option field instance because we need
// access to the available options. We can figure that out by looking looping
// over the provided fields.
const singleSelectField = [primary]
.concat(fields)
.find((field) => field.id === getters.getSingleSelectFieldId)
const singleSelectFieldType = this.$registry.get(
'field',
SingleSelectFieldType.getType()
)
// We immediately want to update the single select value in the row, so we need
// to extract the correct old value and the new value from the single select field
// because that object holds all the options.
const singleSelectFieldName = `field_${getters.getSingleSelectFieldId}`
const oldSingleSelectFieldValue = row[singleSelectFieldName]
const newSingleSelectFieldValue =
singleSelectField.select_options.find(
(option) => option.id === parseInt(currentStackId)
) || null
// Prepare the objects that are needed to update the row directly in the store.
const newValues = {}
const oldValues = {}
newValues[singleSelectFieldName] = newSingleSelectFieldValue
oldValues[singleSelectFieldName] = oldSingleSelectFieldValue
// Because the backend might accept a different format, we need to prepare the
// values that we're going to send.
const newValuesForUpdate = {}
newValuesForUpdate[singleSelectFieldName] =
singleSelectFieldType.prepareValueForUpdate(
singleSelectField,
newSingleSelectFieldValue
)
// Immediately update the row in the store and stop the dragging state.
commit('UPDATE_ROW', { row, values: newValues })
commit('STOP_ROW_DRAG', { row })
// If the stack has changed, the value needs to be updated with the backend.
if (originalStackId !== currentStackId) {
commit('INCREASE_COUNT', { stackId: currentStackId })
commit('DECREASE_COUNT', { stackId: originalStackId })
try {
const { data } = await RowService(this.$client).update(
table.id,
row.id,
newValuesForUpdate
)
commit('UPDATE_ROW', { row, values: data })
} catch (error) {
// If for whatever reason updating the value fails, we need to undo the
// things that have changed in the store.
commit('UPDATE_ROW', { row, values: oldValues })
commit('INCREASE_COUNT', { stackId: originalStackId })
commit('DECREASE_COUNT', { stackId: currentStackId })
dispatch('cancelRowDrag', { row, originalStackId })
throw error
}
}
// If the row is not before the same or if the stack has changed, we must update
// the position.
if (
(before || { id: null }).id !== (originalBefore || { id: null }).id ||
originalStackId !== currentStackId
) {
try {
const { data } = await RowService(this.$client).move(
table.id,
row.id,
before !== null ? before.id : null
)
commit('UPDATE_ROW', { row, values: data })
} catch (error) {
dispatch('cancelRowDrag', { row, originalStackId })
throw error
}
}
},
/**
* Cancels the current row drag action by reverting back to the original position
* while respecting any new rows that have been moved into there in the mean time.
*/
cancelRowDrag({ dispatch, getters, commit }, { row, originalStackId }) {
const current = getters.findStackIdAndIndex(row.id)
if (current !== undefined) {
const currentStackId = current[0]
const sortedRows = clone(getters.getStack(originalStackId).results)
if (currentStackId !== originalStackId) {
// Only add the row to the temporary copy if it doesn't live the current stack.
sortedRows.push(row)
}
sortedRows.sort(getRowSortFunction(this.$registry, [], [], null))
const targetIndex = sortedRows.findIndex((r) => r.id === row.id)
dispatch('forceMoveRowTo', {
row,
targetStackId: originalStackId,
targetIndex,
})
commit('STOP_ROW_DRAG', { row })
}
},
/**
* Moves the provided row to the target stack at the provided index.
*
* @param row
* @param targetStackId
* @param targetIndex
*/
forceMoveRowTo({ commit, getters }, { row, targetStackId, targetIndex }) {
const current = getters.findStackIdAndIndex(row.id)
if (current !== undefined) {
const currentStackId = current[0]
const currentIndex = current[1]
if (currentStackId !== targetStackId || currentIndex !== targetIndex) {
commit('MOVE_ROW', {
currentStackId,
currentIndex,
targetStackId,
targetIndex,
})
return true
}
}
return false
},
/**
* Moves the provided existing row before or after the provided target row.
*
* @param row The row object that must be moved.
* @param targetRow Will be placed before or after the provided row.
* @param targetBefore Indicates whether the row must be moved before or after
* the target row.
*/
forceMoveRowBefore({ dispatch, getters }, { row, targetRow, targetBefore }) {
const target = getters.findStackIdAndIndex(targetRow.id)
if (target !== undefined) {
const targetStackId = target[0]
const targetIndex = target[1] + (targetBefore ? 0 : 1)
return dispatch('forceMoveRowTo', { row, targetStackId, targetIndex })
}
return false
},
/**
* Updates the value of a row and make the changes to the store accordingly.
*/
async updateRowValue(
{ commit, dispatch },
{ table, row, field, fields, primary, value, oldValue }
) {
const fieldType = this.$registry.get('field', field._.type.type)
const allFields = [primary].concat(fields)
const newValues = {}
const newValuesForUpdate = {}
const oldValues = {}
const fieldName = `field_${field.id}`
newValues[fieldName] = value
newValuesForUpdate[fieldName] = fieldType.prepareValueForUpdate(
field,
value
)
oldValues[fieldName] = oldValue
allFields.forEach((fieldToCall) => {
const fieldType = this.$registry.get('field', fieldToCall._.type.type)
const fieldToCallName = `field_${fieldToCall.id}`
const currentFieldValue = row[fieldToCallName]
const optimisticFieldValue = fieldType.onRowChange(
row,
field,
value,
oldValue,
fieldToCall,
currentFieldValue
)
if (currentFieldValue !== optimisticFieldValue) {
newValues[fieldToCallName] = optimisticFieldValue
oldValues[fieldToCallName] = currentFieldValue
}
})
await dispatch('updatedExistingRow', {
row,
values: newValues,
field,
primary,
})
try {
const { data } = await RowService(this.$client).update(
table.id,
row.id,
newValuesForUpdate
)
commit('UPDATE_ROW', { row, values: data })
} catch (error) {
dispatch('updatedExistingRow', { row, values: oldValues, field, primary })
throw error
}
},
/**
* Creates a new stack by updating the related field option of the view's
* field. The values in the store also be updated accordingly.
*/
async createStack(
{ getters, commit, dispatch },
{ fields, primary, color, value }
) {
const field = [primary]
.concat(fields)
.find((field) => field.id === getters.getSingleSelectFieldId)
const updateValues = {
type: field.type,
select_options: clone(field.select_options),
}
updateValues.select_options.push({ color, value })
// Instead of using the field store, we manually update the existing field
// because we need to extract the newly created select option id from the
// response before the field is updated in the store.
const { data } = await FieldService(this.$client).update(
field.id,
updateValues
)
// Extract the newly created select option id from the response and create an
// empty stack with that id. The stack must exist before the field is updated
// in the store, otherwise we could ran into vue errors because the stack is
// expected.
const selectOptionId =
data.select_options[data.select_options.length - 1].id.toString()
const stackObject = populateStack({
count: 0,
results: [],
})
commit('ADD_STACK', { id: selectOptionId, stack: stackObject })
// After the stack has been created, we can update the field in the store.
await dispatch(
'field/forceUpdate',
{
field,
oldField: clone(field),
data,
relatedFields: data.related_fields,
},
{ root: true }
)
},
/**
* Updates the stack by updating the related field option of the view's field. The
* values in the store also be updated accordingly.
*/
async updateStack(
{ getters, commit, dispatch },
{ fields, primary, optionId, values }
) {
const field = [primary]
.concat(fields)
.find((field) => field.id === getters.getSingleSelectFieldId)
const options = clone(field.select_options)
const index = options.findIndex((o) => o.id === optionId)
Object.assign(options[index], values)
const updateValues = {
type: field.type,
select_options: options,
}
const { data } = await FieldService(this.$client).update(
field.id,
updateValues
)
commit('UPDATE_VALUE_OF_ALL_ROWS_IN_STACK', {
fieldId: field.id,
stackId: optionId.toString(),
values,
})
// After the stack has been updated, we can update the field in the store.
await dispatch(
'field/forceUpdate',
{
field,
oldField: clone(field),
data,
relatedFields: data.related_fields,
},
{ root: true }
)
},
/**
* Deletes an existing by updating the related field option of the view's single
* select field.
*/
async deleteStack(
{ getters, commit, dispatch },
{ fields, primary, optionId, deferredFieldUpdate = false }
) {
const field = [primary]
.concat(fields)
.find((field) => field.id === getters.getSingleSelectFieldId)
const options = clone(field.select_options)
const index = options.findIndex((o) => o.id === optionId)
options.splice(index, 1)
const updateValues = {
type: field.type,
select_options: options,
}
const { data } = await FieldService(this.$client).update(
field.id,
updateValues
)
const doFieldUpdate = async () => {
// After the stack has been updated, we can update the field in the store.
await dispatch(
'field/forceUpdate',
{
field,
oldField: clone(field),
data,
relatedFields: data.related_fields,
},
{ root: true }
)
}
return deferredFieldUpdate ? doFieldUpdate : doFieldUpdate()
},
}
export const getters = {
getLastKanbanId(state) {
return state.lastKanbanId
},
getSingleSelectFieldId(state) {
return state.singleSelectFieldId
},
getAllFieldOptions(state) {
return state.fieldOptions
},
getAllStacks: (state) => {
return state.stacks
},
getStack: (state) => (id) => {
return state.stacks[id.toString()]
},
stackExists: (state) => (id) => {
return Object.prototype.hasOwnProperty.call(state.stacks, id.toString())
},
getBufferRequestSize(state) {
return state.bufferRequestSize
},
isDraggingRow(state) {
return !!state.draggingRow
},
getDraggingRow(state) {
return state.draggingRow
},
getDraggingOriginalStackId(state) {
return state.draggingOriginalStackId
},
getDraggingOriginalBefore(state) {
return state.draggingOriginalBefore
},
getAllRows(state) {
let rows = []
Object.keys(state.stacks).forEach((key) => {
rows = rows.concat(state.stacks[key].results)
})
return rows
},
findStackIdAndIndex: (state) => (rowId) => {
const stacks = state.stacks
const keys = Object.keys(stacks)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const results = stacks[key].results
for (let i2 = 0; i2 < results.length; i2++) {
const result = results[i2]
if (result.id === rowId) {
return [key, i2, result]
}
}
}
},
}
export default {
namespaced: true,
state,
getters,
actions,
mutations,
}

View file

@ -0,0 +1,205 @@
import {
maxPossibleOrderValue,
ViewType,
} from '@baserow/modules/database/viewTypes'
import { SingleSelectFieldType } from '@baserow/modules/database/fieldTypes'
import KanbanView from '@baserow_premium/components/views/kanban/KanbanView'
import KanbanViewHeader from '@baserow_premium/components/views/kanban/KanbanViewHeader'
import { PremiumPlugin } from '@baserow_premium/plugins'
class PremiumViewType extends ViewType {
getDeactivatedText() {
return this.app.i18n.t('premium.deactivated')
}
isDeactivated() {
return !PremiumPlugin.hasValidPremiumLicense(
this.app.store.getters['auth/getAdditionalUserData']
)
}
}
export class KanbanViewType extends PremiumViewType {
static getType() {
return 'kanban'
}
getIconClass() {
return 'trello fab'
}
getColorClass() {
return 'color-success'
}
getName() {
return 'Kanban'
}
canFilter() {
return false
}
canSort() {
return false
}
getHeaderComponent() {
return KanbanViewHeader
}
getComponent() {
return KanbanView
}
async fetch({ store }, view, fields, primary, storePrefix = '') {
// If the single select field is `null` we can't fetch the initial data anyway,
// we don't have to do anything. The KanbanView component will handle it by
// showing a form to choose or create a single select field.
if (view.single_select_field === null) {
await store.dispatch(storePrefix + 'view/kanban/reset')
} else {
await store.dispatch(storePrefix + 'view/kanban/fetchInitial', {
kanbanId: view.id,
singleSelectFieldId: view.single_select_field,
})
}
}
async refresh(
{ store },
view,
fields,
primary,
storePrefix = '',
includeFieldOptions = false
) {
try {
await store.dispatch(storePrefix + 'view/kanban/fetchInitial', {
kanbanId: view.id,
singleSelectFieldId: view.single_select_field,
includeFieldOptions,
})
} catch (error) {
if (
error.handler.code === 'ERROR_KANBAN_VIEW_HAS_NO_SINGLE_SELECT_FIELD'
) {
store.dispatch(storePrefix + 'view/kanban/reset')
} else {
throw error
}
}
}
async fieldOptionsUpdated({ store }, view, fieldOptions, storePrefix) {
await store.dispatch(
storePrefix + 'view/kanban/forceUpdateAllFieldOptions',
fieldOptions,
{
root: true,
}
)
}
updated(context, view, oldView, storePrefix) {
// If the single select field has changed, we want to trigger a refresh of the
// page by returning true.
return view.single_select_field !== oldView.single_select_field
}
async rowCreated(
{ store },
tableId,
fields,
primary,
values,
metadata,
storePrefix = ''
) {
if (this.isCurrentView(store, tableId)) {
await store.dispatch(storePrefix + 'view/kanban/createdNewRow', {
fields,
primary,
values,
})
}
}
async rowUpdated(
{ store },
tableId,
fields,
primary,
row,
values,
metadata,
storePrefix = ''
) {
if (this.isCurrentView(store, tableId)) {
await store.dispatch(storePrefix + 'view/kanban/updatedExistingRow', {
fields,
primary,
row,
values,
})
}
}
async rowDeleted({ store }, tableId, fields, primary, row, storePrefix = '') {
if (this.isCurrentView(store, tableId)) {
await store.dispatch(storePrefix + 'view/kanban/deletedExistingRow', {
row,
})
}
}
async fieldCreated({ dispatch }, table, field, fieldType, storePrefix = '') {
await dispatch(
storePrefix + 'view/kanban/setFieldOptionsOfField',
{
field,
// The default values should be the same as in the `KanbanViewFieldOptions`
// model in the backend to stay consistent.
values: {
hidden: true,
order: maxPossibleOrderValue,
},
},
{ root: true }
)
}
_setSingleSelectFieldToNull({ rootGetters, dispatch }, field) {
rootGetters['view/getAll']
.filter((view) => view.type === this.type)
.forEach((view) => {
if (view.single_select_field === field.id) {
dispatch(
'view/forceUpdate',
{
view,
values: { single_select_field: null },
},
{ root: true }
)
}
})
}
fieldUpdated(context, field, oldField, fieldType, storePrefix) {
// If the field type has changed from a single select field to something else,
// it could be that there are kanban views that depending on that field. So we
// need to change to type to null if that's the case.
const type = SingleSelectFieldType.getType()
if (oldField.type === type && field.type !== type) {
this._setSingleSelectFieldToNull(context, field)
}
}
fieldDeleted(context, field, fieldType, storePrefix = '') {
// We want to loop over all kanban views that we have in the store and check if
// they were depending on this deleted field. If that's case, we can set it to null
// because it doesn't exist anymore.
this._setSingleSelectFieldToNull(context, field)
}
}

View file

@ -0,0 +1,298 @@
import kanbanStore from '@baserow_premium/store/view/kanban'
import { TestApp } from '@baserow/test/helpers/testApp'
describe('Kanban view store', () => {
let testApp = null
let store = null
beforeEach(() => {
testApp = new TestApp()
store = testApp.store
})
afterEach(() => {
testApp.afterEach()
})
test('createdNewRow', async () => {
const stacks = {}
stacks.null = {
count: 1,
results: [{ id: 2, order: '2.00', field_1: null }],
}
stacks['1'] = {
count: 100,
results: [
{ id: 10, order: '10.00', field_1: { id: 1 } },
{ id: 11, order: '11.00', field_1: { id: 1 } },
],
}
const state = Object.assign(kanbanStore.state(), {
singleSelectFieldId: 1,
stacks,
})
kanbanStore.state = () => state
store.registerModule('kanban', kanbanStore)
const fields = []
const primary = {
id: 1,
name: 'Single select',
type: 'single_select',
options: [{ id: 1, color: 'blue', value: '' }],
primary: true,
}
await store.dispatch('kanban/createdNewRow', {
values: {
id: 1,
order: '1.00',
field_1: null,
},
fields,
primary,
})
await store.dispatch('kanban/createdNewRow', {
values: {
id: 3,
order: '3.00',
field_1: null,
},
fields,
primary,
})
expect(store.state.kanban.stacks.null.count).toBe(3)
expect(store.state.kanban.stacks.null.results.length).toBe(3)
expect(store.state.kanban.stacks.null.results[0].id).toBe(1)
expect(store.state.kanban.stacks.null.results[1].id).toBe(2)
expect(store.state.kanban.stacks.null.results[2].id).toBe(3)
await store.dispatch('kanban/createdNewRow', {
values: {
id: 9,
order: '9.00',
field_1: { id: 1 },
},
fields,
primary,
})
await store.dispatch('kanban/createdNewRow', {
values: {
id: 12,
order: '12.00',
field_1: { id: 1 },
},
fields,
primary,
})
expect(store.state.kanban.stacks['1'].count).toBe(102)
expect(store.state.kanban.stacks['1'].results.length).toBe(3)
expect(store.state.kanban.stacks['1'].results[0].id).toBe(9)
expect(store.state.kanban.stacks['1'].results[1].id).toBe(10)
expect(store.state.kanban.stacks['1'].results[2].id).toBe(11)
})
test('deletedExistingRow', async () => {
const stacks = {}
stacks.null = {
count: 1,
results: [{ id: 2, order: '2.00', field_1: null }],
}
stacks['1'] = {
count: 100,
results: [
{ id: 10, order: '10.00', field_1: { id: 1 } },
{ id: 11, order: '11.00', field_1: { id: 1 } },
],
}
const state = Object.assign(kanbanStore.state(), {
singleSelectFieldId: 1,
stacks,
})
kanbanStore.state = () => state
store.registerModule('kanban', kanbanStore)
const fields = []
const primary = {
id: 1,
name: 'Single select',
type: 'single_select',
options: [{ id: 1, color: 'blue', value: '' }],
primary: true,
}
await store.dispatch('kanban/deletedExistingRow', {
row: {
id: 2,
order: '2.00',
field_1: null,
},
fields,
primary,
})
expect(store.state.kanban.stacks.null.count).toBe(0)
expect(store.state.kanban.stacks.null.results.length).toBe(0)
await store.dispatch('kanban/deletedExistingRow', {
row: {
id: 50,
order: '50.00',
field_1: { id: 1 },
},
fields,
primary,
})
await store.dispatch('kanban/deletedExistingRow', {
row: {
id: 10,
order: '10.00',
field_1: { id: 1 },
},
fields,
primary,
})
expect(store.state.kanban.stacks['1'].count).toBe(98)
expect(store.state.kanban.stacks['1'].results.length).toBe(1)
expect(store.state.kanban.stacks['1'].results[0].id).toBe(11)
})
test('updatedExistingRow', async () => {
const stacks = {}
stacks.null = {
count: 1,
results: [{ id: 2, order: '2.00', field_1: null }],
}
stacks['1'] = {
count: 100,
results: [
{ id: 10, order: '10.00', field_1: { id: 1 } },
{
id: 11,
order: '11.00',
field_1: { id: 1 },
_: { mustPersist: true },
},
],
}
const state = Object.assign(kanbanStore.state(), {
singleSelectFieldId: 1,
stacks,
})
kanbanStore.state = () => state
store.registerModule('kanban', kanbanStore)
const fields = []
const primary = {
id: 1,
name: 'Single select',
type: 'single_select',
options: [{ id: 1, color: 'blue', value: '' }],
primary: true,
}
// Should be moved to the first in the buffer
await store.dispatch('kanban/updatedExistingRow', {
row: { id: 11, order: '11.00', field_1: { id: 1 } },
values: {
order: '9.00',
},
fields,
primary,
})
// Should be completely ignored because it's outside of the buffer
await store.dispatch('kanban/updatedExistingRow', {
row: { id: 12, order: '12.00', field_1: { id: 1 } },
values: {
order: '13.00',
},
fields,
primary,
})
// Did not exist before, but has moved within the buffer.
await store.dispatch('kanban/updatedExistingRow', {
row: { id: 8, order: '13.00', field_1: { id: 1 } },
values: {
order: '8.00',
},
fields,
primary,
})
expect(store.state.kanban.stacks['1'].count).toBe(100)
expect(store.state.kanban.stacks['1'].results.length).toBe(3)
expect(store.state.kanban.stacks['1'].results[0].id).toBe(8)
expect(store.state.kanban.stacks['1'].results[1].id).toBe(11)
expect(store.state.kanban.stacks['1'].results[1]._.mustPersist).toBe(true)
expect(store.state.kanban.stacks['1'].results[2].id).toBe(10)
// Moved to stack `null`, because the position is within the buffer, we expect
// it to be added to it.
await store.dispatch('kanban/updatedExistingRow', {
row: { id: 8, order: '8.00', field_1: { id: 1 } },
values: {
field_1: null,
},
fields,
primary,
})
// Moved to stack `null`, because the position is within the buffer, we expect
// it to be added to it.
await store.dispatch('kanban/updatedExistingRow', {
row: { id: 11, order: '9.00', field_1: { id: 1 } },
values: {
field_1: null,
order: '1.00',
},
fields,
primary,
})
expect(store.state.kanban.stacks.null.count).toBe(3)
expect(store.state.kanban.stacks.null.results.length).toBe(3)
expect(store.state.kanban.stacks.null.results[0].id).toBe(11)
expect(store.state.kanban.stacks.null.results[0]._.mustPersist).toBe(true)
expect(store.state.kanban.stacks.null.results[1].id).toBe(2)
expect(store.state.kanban.stacks.null.results[2].id).toBe(8)
expect(store.state.kanban.stacks['1'].count).toBe(98)
expect(store.state.kanban.stacks['1'].results.length).toBe(1)
expect(store.state.kanban.stacks['1'].results[0].id).toBe(10)
// Moved to stack `1`, because the position is within the buffer, we expect
// it to be added to it.
await store.dispatch('kanban/updatedExistingRow', {
row: { id: 2, order: '1.00', field_1: null },
values: {
field_1: { id: 1 },
},
fields,
primary,
})
// Moved to stack `1`, because the position is outside the buffer, we expect it
// not to be in there.
await store.dispatch('kanban/updatedExistingRow', {
row: { id: 11, order: '99.00', field_1: null },
values: {
field_1: { id: 1 },
},
fields,
primary,
})
expect(store.state.kanban.stacks.null.count).toBe(1)
expect(store.state.kanban.stacks.null.results.length).toBe(1)
expect(store.state.kanban.stacks.null.results[0].id).toBe(8)
expect(store.state.kanban.stacks['1'].count).toBe(100)
expect(store.state.kanban.stacks['1'].results.length).toBe(2)
expect(store.state.kanban.stacks['1'].results[0].id).toBe(2)
expect(store.state.kanban.stacks['1'].results[1].id).toBe(10)
})
})

View file

@ -74,5 +74,7 @@
@import 'infinite_scroll';
@import 'formula_field';
@import 'lang_picker';
@import 'card';
@import 'card/all';
@import 'webhook';
@import 'tab';

View file

@ -0,0 +1,31 @@
// Some of the properties must be kept in sync with `KanbanViewStack.vue::cardHeight`.
// This is because the kanban view needs to know the height of each card in order to
// paginate correctly. If properties that influence the height and added or changed, we
// most likely need to make a change in the KanbanView.vue::cardHeight method. These
// are properties like margin-top, margin-bottom, padding-top, padding-bottom, height,
// line-height, etc
.card {
background-color: $white;
box-shadow: 0 1px 3px 0 rgba($black, 0.16);
border-radius: 3px;
padding: 16px 0;
}
.card__field {
padding: 0 16px;
&:not(:last-child) {
margin-bottom: 10px;
}
}
.card__field-name {
font-size: 12px;
line-height: 14px;
font-weight: 600;
margin-bottom: 6px;
}
.card__field-value {
width: 100%;
}

View file

@ -0,0 +1,7 @@
@import 'text';
@import 'boolean';
@import 'many_to_many';
@import 'link_row';
@import 'single_select';
@import 'multiple_select';
@import 'file';

View file

@ -0,0 +1,6 @@
.card-boolean {
font-size: 13px;
line-height: 13px;
height: 13px;
color: $color-success-500;
}

View file

@ -0,0 +1,50 @@
.card-file__list-wrapper {
overflow: hidden;
margin: 0 -16px;
height: 22px;
}
.card-file__list {
overflow: hidden;
display: flex;
flex-wrap: nowrap;
height: 22px + 15px;
list-style: none;
padding: 0 16px 15px 16px;
margin: 0;
overflow-x: scroll;
}
%card-file__border {
border-radius: 3px;
border: solid 1px $color-neutral-300;
}
.card-file__item {
white-space: nowrap;
display: flex;
height: 22px;
&:not(:last-child) {
margin-right: 4px;
}
}
.card-file__image {
@extend %card-file__border;
display: block;
width: auto;
height: 22px;
}
.card-file__icon {
@extend %card-file__border;
display: flex;
align-items: center;
justify-content: center;
color: $color-neutral-600;
width: 22px;
height: 22px;
}

View file

@ -0,0 +1,9 @@
.card-link-row {
padding: 0 5px;
background-color: $color-primary-100;
border-radius: 3px;
}
.card-link-row--unnamed {
color: $color-neutral-600;
}

View file

@ -0,0 +1,31 @@
.card-many-to-many__list-wrapper {
overflow: hidden;
margin: 0 -16px;
height: 22px;
}
.card-many-to-many__list {
overflow: hidden;
display: flex;
flex-wrap: nowrap;
height: 22px + 15px;
padding: 0 16px 15px 16px;
overflow-x: scroll;
}
.card-many-to-many__item {
white-space: nowrap;
display: flex;
@include fixed-height(22px, 13px);
&:not(:last-child) {
margin-right: 5px;
}
}
.card-many-to-many__name {
@extend %ellipsis;
max-width: 140px;
}

View file

@ -0,0 +1,5 @@
.card-multiple-select-option {
overflow: visible;
@include select-option-style(flex);
}

View file

@ -0,0 +1,9 @@
.card-single-select-option__wrapper {
height: 20px;
}
.card-single-select-option {
@extend %ellipsis;
@include select-option-style(inline-block);
}

View file

@ -0,0 +1,9 @@
.card-text {
@extend %ellipsis;
width: 100%;
font-size: 13px;
min-width: 1px;
line-height: 16px;
height: 16px;
}

View file

@ -61,7 +61,7 @@
}
&.deactivated {
background-color: $color-neutral-100;
background-color: $color-neutral-50;
color: $color-neutral-400;
&:hover {

View file

@ -108,7 +108,9 @@
border-top: 1px solid $color-neutral-200;
padding-top: 20px;
margin-top: 20px;
text-align: right;
display: flex;
align-items: center;
justify-content: right;
}
.context__alert {

View file

@ -220,6 +220,26 @@
&::before {
background-color: $color-neutral-50;
}
&.selected::before {
background-color: $color-neutral-500;
border-color: $color-neutral-500;
}
}
&.radio--loading {
cursor: inherit;
&::before {
display: none;
}
&::after {
content: '';
@include loading(14px);
@include absolute(7px, auto, 0, 1px);
}
}
}

View file

@ -38,9 +38,10 @@
}
.hidings__list {
position: relative;
list-style: none;
margin: 0;
padding: 8px 0 0 0;
margin: 8px 0 0 0;
padding: 0;
}
.hidings__item {
@ -48,10 +49,23 @@
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 6px;
margin-bottom: 2px;
padding: 0;
}
.hidings__item-handle {
width: 8px;
height: 16px;
margin-right: 8px;
background-image: radial-gradient($color-neutral-200 40%, transparent 40%);
background-size: 4px 4px;
background-repeat: repeat;
&:hover {
background-image: radial-gradient($color-neutral-500 40%, transparent 40%);
}
}
.hidings__footer {
display: flex;
width: 100%;

View file

@ -215,10 +215,19 @@
color: $color-primary-900;
border-radius: 3px;
&:hover {
&:not(.select__footer-multiple-item--disabled):hover {
text-decoration: none;
background-color: $color-neutral-100;
}
&.select__footer-multiple-item--disabled {
color: $color-neutral-400;
&:hover {
cursor: inherit;
text-decoration: none;
}
}
}
.select__footer-multiple-icon {

View file

@ -77,6 +77,14 @@
margin-bottom: 40px !important;
}
.margin-left-auto {
margin-left: auto !important;
}
.margin-left-0 {
margin-left: 0 !important;
}
.margin-left-1 {
margin-left: 8px !important;
}
@ -85,6 +93,14 @@
margin-left: 16px !important;
}
.margin-right-auto {
margin-right: auto !important;
}
.margin-right-0 {
margin-right: 0 !important;
}
.margin-right-1 {
margin-right: 8px !important;
}

View file

@ -15,16 +15,23 @@ export default {
value: {
type: [String, Number, Boolean, Object],
required: false,
default: '',
},
modelValue: {
type: [String, Number, Boolean, Object],
required: false,
default: '',
},
disabled: {
type: Boolean,
required: false,
default: false,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
classNames() {
@ -34,13 +41,17 @@ export default {
'default'
),
'radio--disabled': this.disabled,
'radio--loading': this.loading,
selected: this.modelValue === this.value,
}
},
selected() {
return this.modelValue === this.value
},
},
methods: {
select(value) {
if (this.disabled) {
if (this.disabled || this.selected) {
return
}
this.$emit('input', value)

View file

@ -4,6 +4,7 @@
:class="{ 'infinite-scroll--reversed': reverse }"
class="infinite-scroll"
@scroll="handleScroll"
v-on="$listeners"
>
<slot />
<div
@ -13,7 +14,10 @@
>
<div v-if="loading" class="loading"></div>
</div>
<slot v-if="currentCount >= maxCount && wrapperHasScrollbar" name="end">
<slot
v-if="renderEnd && currentCount >= maxCount && wrapperHasScrollbar"
name="end"
>
<div class="infinite-scroll__end-line"></div>
</slot>
</section>
@ -58,6 +62,11 @@ export default {
required: false,
default: false,
},
renderEnd: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {

View file

@ -0,0 +1,110 @@
/**
* A directive that helps with auto scrolling when the mouse pointer reaches the
* edge of the element.
*
* v-auto-scroll="{
* // Indicates whether we should scroll vertically or horizontally by providing
* // `vertical` or `horizontal`.
* orientation: 'vertical'
* // Dynamic indication if the auto scrolling is enabled. This is typically used
* // we should only auto scroll if dragging a certain element.
* enabled: () => true,
* // The speed that should be scrolled with.
* speed: 3,
* // Indicates the percentage of the edges that should trigger the auto
* // scrolling. If 10 is provided, then the auto scrolling starts when the mouse
* // pointer is in 10% of the edge of the element.
* padding: 10,
* // The element that should be scrolled in. By default this is the element
* // that's binding by the directive, but it can optionally be changed.
* scrollElement: () => DOMElement
* }"
*/
export default {
bind(el, binding) {
binding.def.update(el, binding)
el.autoScrollTimeout = null
el.autoScrollLastMoveEvent = null
const autoscrollLoop = () => {
if (!el.autoScrollConfig.enabled()) {
clearTimeout(el.autoScrollTimeout)
el.autoScrollTimeout = null
return
}
const scrollElement = el.autoScrollConfig.scrollElement()
const rect = scrollElement.getBoundingClientRect()
let size
let autoScrollMouseStart
if (el.autoScrollConfig.orientation === 'horizontal') {
size = rect.right - rect.left
autoScrollMouseStart = el.autoScrollLastMoveEvent.clientX - rect.left
} else {
size = rect.bottom - rect.top
autoScrollMouseStart = el.autoScrollLastMoveEvent.clientY - rect.top
}
const autoScrollMouseEnd = size - autoScrollMouseStart
const side = Math.ceil((size / 100) * el.autoScrollConfig.padding)
let speed = 0
if (autoScrollMouseStart < side) {
speed = -(
el.autoScrollConfig.speed -
Math.ceil(
(Math.max(0, autoScrollMouseStart) / side) *
el.autoScrollConfig.speed
)
)
} else if (autoScrollMouseEnd < side) {
speed =
el.autoScrollConfig.speed -
Math.ceil(
(Math.max(0, autoScrollMouseEnd) / side) * el.autoScrollConfig.speed
)
}
// If the speed is either a positive or negative, so not 0, we know that we
// need to start auto scrolling.
if (speed !== 0) {
if (el.autoScrollConfig.orientation === 'horizontal') {
scrollElement.scrollLeft += speed
} else {
scrollElement.scrollTop += speed
}
el.autoScrollTimeout = setTimeout(() => {
autoscrollLoop()
}, 2)
} else {
clearTimeout(el.autoScrollTimeout)
el.autoScrollTimeout = null
}
}
el.autoScrollMouseMoveEvent = (event) => {
event.preventDefault()
el.autoScrollLastMoveEvent = event
if (el.autoScrollTimeout === null) {
autoscrollLoop()
}
}
el.addEventListener('mousemove', el.autoScrollMouseMoveEvent)
},
update(el, binding) {
const defaultEnabled = () => true
const defaultScrollElement = () => el
el.autoScrollConfig = {
orientation: binding.value.orientation || 'vertical',
enabled: binding.value.enabled || defaultEnabled,
speed: binding.value.speed || 3,
padding: binding.value.padding || 10,
scrollElement: binding.value.scrollElement || defaultScrollElement,
}
},
unbind(el) {
el.removeEventListener('mousemove', el.autoScrollMouseMoveEvent)
},
}

View file

@ -52,7 +52,7 @@ export default {
: el
el.mousedownEvent = (event) => {
if (!el.sortableEnabled) {
if (!el.sortableEnabled || event.button !== 0) {
return
}

View file

@ -63,15 +63,15 @@ import { copyToClipboard } from '@baserow/modules/database/utils/clipboard'
export default {
layout: 'app',
middleware: 'staff',
async asyncData({ app }) {
const { data } = await SettingsService(app.$client).getInstanceID()
return { instanceId: data.instance_id }
},
computed: {
...mapGetters({
settings: 'settings/get',
}),
},
async asyncData({ app }) {
const { data } = await SettingsService(app.$client).getInstanceID()
return { instanceId: data.instance_id }
},
methods: {
async updateSettings(values) {
try {

View file

@ -141,6 +141,7 @@
<Radio v-model="radio" value="b">Option B</Radio>
<Radio v-model="radio" value="c">Option C</Radio>
<Radio v-model="radio" value="d" :disabled="true">Option D</Radio>
<Radio v-model="radio" value="e" :loading="true">Option E</Radio>
</div>
</div>
<div class="control">
@ -1112,6 +1113,188 @@
</div>
</div>
</div>
<div class="margin-bottom-3">
<div class="card" style="width: 320px">
<div class="card__field">
<div class="card__field-name">Text</div>
<div class="card__field-value">
<div class="card-text">This is a single line text field</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Long text</div>
<div class="card__field-value">
<div class="card-text">
This is a long text field with a very long content that
doesn't fit.
</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Link row</div>
<div class="card__field-value">
<div class="card-many-to-many__list-wrapper">
<div class="card-many-to-many__list">
<div class="card-many-to-many__item card-link-row">
<span class="card-many-to-many__name">
Value 1 with a very long name that doesn't
</span>
</div>
<div
class="
card-many-to-many__item
card-link-row card-link-row--unnamed
"
>
<span class="card-many-to-many__name">
unnamed row 1
</span>
</div>
<div class="card-many-to-many__item card-link-row">
<span class="card-many-to-many__name">
Another value
</span>
</div>
</div>
</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Number</div>
<div class="card__field-value">
<div class="card-text">205</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Decimal</div>
<div class="card__field-value">
<div class="card-text">205.55</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Boolean</div>
<div class="card__field-value">
<div class="card-boolean">
<i class="fas fa-check"></i>
</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Date</div>
<div class="card__field-value">
<div class="card-text">2021-01-01</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Datetime</div>
<div class="card__field-value">
<div class="card-text">2021-01-01</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">URL</div>
<div class="card__field-value">
<div class="card-text">
<a href="#">http://baserow.io</a>
</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Email</div>
<div class="card__field-value">
<div class="card-text">
<a href="#">bram@baserow.io</a>
</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">File</div>
<div class="card__field-value">
<ul class="card-file__list">
<li class="card-file__item">
<img
src="http://localhost:4000/media/thumbnails/tiny/NcTfu10MwH9xtUkzL5jcmqIDXE0vkHin_1e01581444c66c952aa585f6fc79a671885b75053d275329f20e7b5faa73d7ec.png"
class="card-file__image"
/>
</li>
<li class="card-file__item">
<img
src="http://localhost:4000/media/thumbnails/tiny/E413HC1eHsw9gakeRrpUOodHprEBT1pv_01bb5d9bbbd9addb39d1352877ca061573f2ca8ba64631ae5d4b7f8e16f6b18b.png"
class="card-file__image"
/>
</li>
<li class="card-file__item">
<i class="fas card-file__icon fa-file"></i>
</li>
</ul>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Single select</div>
<div class="card__field-value">
<div class="card-single-select-option background-color--orange">
Option 1 with a very long name that doesn't fit unfortunately.
</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Single select</div>
<div class="card__field-value">
<div class="card-single-select-option background-color--gray">
Option 2
</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Multiple select</div>
<div class="card__field-value">
<div class="card-many-to-many__list-wrapper">
<div class="card-many-to-many__list">
<div
class="
card-many-to-many__item
card-multiple-select-option
background-color--green
"
>
<span class="card-many-to-many__name">
Option value 1
</span>
</div>
<div
class="
card-many-to-many__item
card-multiple-select-option
background-color--blue
"
>
<span class="card-many-to-many__name">
Option 2 with a very long name that doesn't fit
</span>
</div>
<div
class="
card-many-to-many__item
card-multiple-select-option
background-color--blue
"
>
<span class="card-many-to-many__name"> Option 3 </span>
</div>
</div>
</div>
</div>
</div>
<div class="card__field">
<div class="card__field-name">Phone</div>
<div class="card__field-value">
<div class="card-text">
<a href="#">+316 12345678</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -23,6 +23,7 @@ import tooltip from '@baserow/modules/core/directives/tooltip'
import sortable from '@baserow/modules/core/directives/sortable'
import autoOverflowScroll from '@baserow/modules/core/directives/autoOverflowScroll'
import userFileUpload from '@baserow/modules/core/directives/userFileUpload'
import autoScroll from '@baserow/modules/core/directives/autoScroll'
Vue.component('Context', Context)
Vue.component('Modal', Modal)
@ -47,3 +48,4 @@ Vue.directive('tooltip', tooltip)
Vue.directive('sortable', sortable)
Vue.directive('autoOverflowScroll', autoOverflowScroll)
Vue.directive('userFileUpload', userFileUpload)
Vue.directive('autoScroll', autoScroll)

View file

@ -18,7 +18,7 @@ export class DatabaseApplicationType extends ApplicationType {
* @return The component to use as the row edit modal's right sidebar or null to not
* use one.
*/
getRowEditModalRightSidebarComponent() {
getRowEditModalRightSidebarComponent(readOnly) {
return null
}

View file

@ -0,0 +1,35 @@
<template>
<div class="card" v-on="$listeners">
<div v-for="field in fields" :key="field.id" class="card__field">
<div class="card__field-name">{{ field.name }}</div>
<div class="card__field-value">
<component
:is="getCardComponent(field)"
:field="field"
:value="row['field_' + field.id]"
/>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'RowCard',
props: {
fields: {
type: Array,
required: true,
},
row: {
type: Object,
required: true,
},
},
methods: {
getCardComponent(field) {
return this.$registry.get('field', field.type).getCardComponent()
},
},
}
</script>

View file

@ -0,0 +1,11 @@
<template functional>
<div class="card-boolean">
<i v-if="!!props.value" class="fas fa-check"></i>
</div>
</template>
<script>
export default {
height: 13,
}
</script>

View file

@ -0,0 +1,18 @@
<template functional>
<div class="card-text">
{{ $options.methods.getDate(props.field, props.value) }}
<template v-if="props.field.date_include_time">{{
$options.methods.getTime(props.field, props.value)
}}</template>
</div>
</template>
<script>
import readOnlyDateField from '@baserow/modules/database/mixins/readOnlyDateField'
export default {
height: 16,
name: 'RowCardFieldDate',
mixins: [readOnlyDateField],
}
</script>

View file

@ -0,0 +1,11 @@
<template functional>
<div class="card-text">
<a :href="'mailto:' + props.value" target="_blank">{{ props.value }}</a>
</div>
</template>
<script>
export default {
height: 16,
}
</script>

View file

@ -0,0 +1,36 @@
<template functional>
<div class="card-file__list-wrapper">
<ul class="card-file__list">
<li
v-for="(file, index) in props.value"
:key="file.name + index"
class="card-file__item"
>
<img
v-if="file.is_image"
class="card-file__image"
:src="file.thumbnails.tiny.url"
/>
<i
v-else
class="fas card-file__icon"
:class="'fa-' + $options.methods.getIconClass(file.mime_type)"
></i>
</li>
</ul>
</div>
</template>
<script>
import { mimetype2fa } from '@baserow/modules/core/utils/fontawesome'
export default {
height: 22,
name: 'RowCardFieldFile',
methods: {
getIconClass(mimeType) {
return mimetype2fa(mimeType)
},
},
}
</script>

View file

@ -0,0 +1,40 @@
<template functional>
<component
:is="$options.methods.getComponent(props.field)"
v-if="$options.methods.getComponent(props.field)"
:field="props.field"
:value="props.value"
></component>
<div v-else class="card-text">Unknown Field Type</div>
</template>
<script>
import RowCardFieldDate from '@baserow/modules/database/components/card/RowCardFieldDate'
import RowCardFieldBoolean from '@baserow/modules/database/components/card/RowCardFieldBoolean'
import RowCardFieldNumber from '@baserow/modules/database/components/card/RowCardFieldNumber'
import RowCardFieldText from '@baserow/modules/database/components/card/RowCardFieldText'
export default {
height: 0, // @TODO make this work for the formula
name: 'RowCardFieldFormula',
components: {
RowCardFieldDate,
RowCardFieldBoolean,
RowCardFieldNumber,
RowCardFieldText,
},
methods: {
getComponent(field) {
return {
date: RowCardFieldDate,
text: RowCardFieldText,
boolean: RowCardFieldBoolean,
number: RowCardFieldNumber,
invalid: RowCardFieldText,
char: RowCardFieldText,
date_interval: RowCardFieldText,
}[field.formula_type]
},
},
}
</script>

View file

@ -0,0 +1,24 @@
<template functional>
<div class="card-many-to-many__list-wrapper">
<div class="card-many-to-many__list">
<div
v-for="item in props.value"
:key="item.id"
class="card-many-to-many__item card-link-row"
:class="{
'card-link-row--unnamed': item.value === null || item.value === '',
}"
>
<span class="card-many-to-many__name">
{{ item.value || 'unnamed row ' + item.id }}
</span>
</div>
</div>
</div>
</template>
<script>
export default {
height: 22,
}
</script>

View file

@ -0,0 +1,20 @@
<template functional>
<div class="card-many-to-many__list-wrapper">
<div class="card-many-to-many__list">
<div
v-for="item in props.value"
:key="item.id"
class="card-many-to-many__item card-multiple-select-option"
:class="'background-color--' + item.color"
>
<span class="card-many-to-many__name">{{ item.value }}</span>
</div>
</div>
</div>
</template>
<script>
export default {
height: 22,
}
</script>

View file

@ -0,0 +1,11 @@
<template functional>
<div class="card-text">
{{ props.value !== 'NaN' ? props.value : 'Invalid Number' }}
</div>
</template>
<script>
export default {
height: 16,
}
</script>

View file

@ -0,0 +1,11 @@
<template functional>
<div class="card-text">
<a :href="'tel:' + props.value" target="_blank">{{ props.value }}</a>
</div>
</template>
<script>
export default {
height: 16,
}
</script>

View file

@ -0,0 +1,17 @@
<template functional>
<div class="card-single-select-option__wrapper">
<div
v-if="props.value"
class="card-single-select-option"
:class="'background-color--' + props.value.color"
>
{{ props.value.value }}
</div>
</div>
</template>
<script>
export default {
height: 20,
}
</script>

View file

@ -0,0 +1,11 @@
<template functional>
<div class="card-text">
{{ props.value }}
</div>
</template>
<script>
export default {
height: 16,
}
</script>

View file

@ -0,0 +1,11 @@
<template functional>
<div class="card-text">
<a :href="props.value" target="_blank" rel="nofollow">{{ props.value }}</a>
</div>
</template>
<script>
export default {
height: 16,
}
</script>

View file

@ -141,7 +141,6 @@ export default {
this.job.status === 'failed'
? this.$t('exportTableModal.failedDescription')
: this.$t('exportTableModal.cancelledDescription')
console.log(title, message)
this.showError(title, message)
}
} catch (error) {

View file

@ -1,6 +1,11 @@
<template>
<Context ref="context">
<FieldForm ref="form" :table="table" @submitted="submit">
<FieldForm
ref="form"
:table="table"
:forced-type="forcedType"
@submitted="submit"
>
<div class="context__form-actions">
<button
class="button"
@ -28,6 +33,11 @@ export default {
type: Object,
required: true,
},
forcedType: {
type: [String, null],
required: false,
default: null,
},
},
data() {
return {

View file

@ -42,7 +42,7 @@
</div>
</div>
</div>
<div class="control">
<div v-if="forcedType === null" class="control">
<div class="control__elements">
<Dropdown
v-model="values.type"
@ -99,13 +99,18 @@ export default {
required: false,
default: false,
},
forcedType: {
type: [String, null],
required: false,
default: null,
},
},
data() {
return {
allowedValues: ['name', 'type'],
values: {
name: '',
type: '',
type: this.forcedType || '',
},
}
},

View file

@ -74,7 +74,7 @@ export default {
// callback must still be called.
const callback = async () => {
await forceUpdateCallback()
this.$refs.form.reset()
this.$refs.form && this.$refs.form.reset()
this.loading = false
this.hide()
this.$emit('updated')

View file

@ -0,0 +1,133 @@
<template>
<Modal ref="modal">
<form @submit.prevent="create">
<h2 v-if="primary !== undefined" class="box__title">
{{ heading }}
</h2>
<Error :error="error"></Error>
<RowEditModalField
v-for="field in allFields"
:key="'row-create-field-' + field.id"
:ref="'field-' + field.id"
:field="field"
:row="row"
:table="table"
:read-only="false"
@update="update"
@field-updated="$emit('field-updated', $event)"
@field-deleted="$emit('field-deleted')"
></RowEditModalField>
<div class="actions">
<div class="align-right">
<button
class="button button--large"
:class="{ 'button--loading': loading }"
:disabled="loading"
>
Create
</button>
</div>
</div>
</form>
</Modal>
</template>
<script>
import Vue from 'vue'
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import RowEditModalField from '@baserow/modules/database/components/row/RowEditModalField'
export default {
name: 'RowCreateModal',
components: {
RowEditModalField,
},
mixins: [modal, error],
props: {
table: {
type: Object,
required: true,
},
primary: {
type: Object,
required: false,
default: undefined,
},
fields: {
type: Array,
required: true,
},
},
data() {
return {
row: {},
loading: false,
}
},
computed: {
allFields() {
return [this.primary]
.concat(this.fields)
.slice()
.sort((a, b) => a.order - b.order)
},
heading() {
const name = `field_${this.primary.id}`
if (Object.prototype.hasOwnProperty.call(this.row, name)) {
return this.$registry
.get('field', this.primary.type)
.toHumanReadableString(this.primary, this.row[name])
} else {
return null
}
},
},
methods: {
show(defaults = {}, ...args) {
const row = {}
this.allFields.forEach((field) => {
const name = `field_${field.id}`
const fieldType = this.$registry.get('field', field._.type.type)
row[name] = fieldType.getNewRowValue(field)
})
Object.assign(row, defaults)
Vue.set(this, 'row', row)
return modal.methods.show.call(this, ...args)
},
update(event) {
const name = `field_${event.field.id}`
this.row[name] = event.value
},
create() {
this.loading = true
this.$emit('created', {
row: this.row,
callback: (error) => {
if (error) {
this.handleError(error)
} else {
this.hide()
}
this.loading = false
},
})
},
},
}
</script>
<i18n>
{
"en": {
"rowCreateModal": {
"addField": "add field"
}
},
"fr": {
"rowCreateModal": {
"addField": "Ajouter une colonne"
}
}
}
</i18n>

View file

@ -50,6 +50,8 @@
</template>
<script>
import { mapGetters } from 'vuex'
import modal from '@baserow/modules/core/mixins/modal'
import RowEditModalField from '@baserow/modules/database/components/row/RowEditModalField'
import CreateFieldContext from '@baserow/modules/database/components/field/CreateFieldContext'
@ -86,52 +88,50 @@ export default {
},
data() {
return {
rowId: -1,
optionalRightSideBar: this.$registry
.get('application', 'database')
.getRowEditModalRightSidebarComponent(),
.getRowEditModalRightSidebarComponent(this.readOnly),
}
},
computed: {
...mapGetters({
rowId: 'rowModal/id',
rowExists: 'rowModal/exists',
row: 'rowModal/row',
}),
},
watch: {
/**
* We need an array containing all available rows because then we can always find
* the most up to the date row based on the provided id. We need to do it this way
* because it is possible that the rows are refreshed after for example a field
* update and we always need the most up to date row. This way eventually prevents
* incompatible row values.
*
* Small side effect is that if the user is editing a row via the modal and another
* user changes the filters of the same view, then the rows are refreshed for both
* users. If the current row is then not in the buffer anymore then the modal does
* not have a data source anymore and is forced to close. This is, in my opinion,
* less bad compared to old/incompatible data after the user changes the field
* type.
* It could happen that the view doesn't always have all the rows buffered. When
* the modal is opened, it will find the correct row by looking through all the
* rows of the view. If a filter changes, the existing row could be removed the
* buffer while the user still wants to edit the row because the modal is open. In
* that case, we will keep a copy in the `rowModal` store, which will also listen
* for real time update events to make sure the latest information is always
* visible. If the buffer of the view changes and the row does exist, we want to
* switch to that version to maintain reactivity between the two.
*/
row() {
const row = this.rows.find((row) => row.id === this.rowId)
if (row === undefined) {
// If the row is not found in the provided rows then we don't have a row data
// source anymore which means we can close the modal.
if (
this.$refs &&
Object.prototype.hasOwnProperty.call(this.$refs, 'modal') &&
this.$refs.modal.open
) {
this.$nextTick(() => {
this.hide()
})
}
return {}
rows(value) {
const row = value.find((r) => r.id === this.rowId)
if (row === undefined && this.rowExists) {
this.$store.dispatch('rowModal/doesNotExist')
} else if (row !== undefined && !this.rowExists) {
this.$store.dispatch('rowModal/doesExist', { row })
}
return row
},
},
methods: {
show(rowId, ...args) {
this.rowId = rowId
const row = this.rows.find((r) => r.id === rowId)
this.$store.dispatch('rowModal/open', {
id: rowId,
row: row || {},
exists: !!row,
})
this.getRootModal().show(...args)
},
hide(...args) {
this.$store.dispatch('rowModal/clear')
this.getRootModal().hide(...args)
},
/**

View file

@ -0,0 +1,52 @@
<template>
<a
ref="createViewLink"
v-tooltip="deactivated ? deactivatedText : null"
class="select__footer-multiple-item"
:class="{
'select__footer-multiple-item--disabled': deactivated,
}"
@click="!deactivated && $refs.createModal.show($refs.createViewLink)"
>
<i
class="select__footer-multiple-icon fas"
:class="'fa-' + viewType.iconClass"
></i>
{{ viewType.getName() }}
<CreateViewModal
ref="createModal"
:table="table"
:view-type="viewType"
@created="$emit('created', $event)"
></CreateViewModal>
</a>
</template>
<script>
import CreateViewModal from '@baserow/modules/database/components/view/CreateViewModal'
export default {
name: 'ViewsContext',
components: {
CreateViewModal,
},
props: {
table: {
type: Object,
required: true,
},
viewType: {
type: Object,
required: true,
},
},
computed: {
deactivatedText() {
return this.viewType.getDeactivatedText()
},
deactivated() {
return this.viewType.isDeactivated()
},
},
}
</script>

View file

@ -17,8 +17,19 @@
<li
v-for="field in filteredFields"
:key="field.id"
v-sortable="{
enabled: !readOnly,
id: field.id,
update: order,
handle: '[data-field-handle]',
}"
class="hidings__item"
>
<a
v-show="!readOnly"
class="hidings__item-handle"
data-field-handle
></a>
<SwitchInput
:value="!isHidden(field.id)"
:disabled="readOnly"
@ -51,32 +62,25 @@
</template>
<script>
import { mapGetters } from 'vuex'
import { escapeRegExp } from '@baserow/modules/core/utils/string'
import context from '@baserow/modules/core/mixins/context'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { clone } from '@baserow/modules/core/utils/object'
import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes'
export default {
name: 'ViewHideContext',
name: 'ViewFieldsContext',
mixins: [context],
props: {
fields: {
type: Array,
required: true,
},
view: {
type: Object,
required: true,
},
readOnly: {
type: Boolean,
required: true,
},
storePrefix: {
type: String,
fieldOptions: {
type: Object,
required: true,
},
},
@ -136,50 +140,28 @@ export default {
})
},
},
beforeCreate() {
this.$options.computed = {
...(this.$options.computed || {}),
...mapGetters({
fieldOptions:
this.$options.propsData.storePrefix + 'view/grid/getAllFieldOptions',
}),
}
},
methods: {
async updateAllFieldOptions(values) {
order(order, oldOrder) {
this.$emit('update-order', { order, oldOrder })
},
updateAllFieldOptions(values) {
const newFieldOptions = {}
const oldFieldOptions = clone(this.fieldOptions)
this.fields.forEach((field) => {
if (!field.primary) {
newFieldOptions[field.id] = values
}
newFieldOptions[field.id] = values
})
try {
await this.$store.dispatch(
this.storePrefix + 'view/grid/updateAllFieldOptions',
{
newFieldOptions,
oldFieldOptions,
}
)
} catch (error) {
notifyIf(error, 'view')
}
this.$emit('update-all-field-options', {
newFieldOptions,
oldFieldOptions,
})
},
async updateFieldOptionsOfField(field, values) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/grid/updateFieldOptionsOfField',
{
field,
values,
oldValues: { hidden: this.fieldOptions[field.id].hidden },
}
)
} catch (error) {
notifyIf(error, 'view')
}
updateFieldOptionsOfField(field, values) {
this.$emit('update-field-options-of-field', {
field,
values,
oldValues: { hidden: this.fieldOptions[field.id].hidden },
})
},
isHidden(fieldId) {
const exists = Object.prototype.hasOwnProperty.call(

View file

@ -37,25 +37,13 @@
<div class="select__footer-multiple-label">
{{ $t('viewsContext.addView') }}
</div>
<a
<CreateViewLink
v-for="(viewType, type) in viewTypes"
:key="type"
:ref="'createViewModalToggle' + type"
class="select__footer-multiple-item"
@click="toggleCreateViewModal(type)"
>
<i
class="select__footer-multiple-icon fas"
:class="'fa-' + viewType.iconClass"
></i>
{{ viewType.getName() }}
<CreateViewModal
:ref="'createViewModal' + type"
:table="table"
:view-type="viewType"
@created="scrollViewDropdownToBottom()"
></CreateViewModal>
</a>
:table="table"
:view-type="viewType"
@created="scrollViewDropdownToBottom()"
></CreateViewLink>
</div>
</div>
</Context>
@ -64,18 +52,18 @@
<script>
import { mapState } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { escapeRegExp } from '@baserow/modules/core/utils/string'
import context from '@baserow/modules/core/mixins/context'
import dropdownHelpers from '@baserow/modules/core/mixins/dropdownHelpers'
import { notifyIf } from '@baserow/modules/core/utils/error'
import ViewsContextItem from '@baserow/modules/database/components/view/ViewsContextItem'
import CreateViewModal from '@baserow/modules/database/components/view/CreateViewModal'
import { escapeRegExp } from '@baserow/modules/core/utils/string'
import CreateViewLink from '@baserow/modules/database/components/view/CreateViewLink'
export default {
name: 'ViewsContext',
components: {
ViewsContextItem,
CreateViewModal,
CreateViewLink,
},
mixins: [context, dropdownHelpers],
props: {
@ -187,10 +175,6 @@ export default {
parentContainerBeforeHeight
)
},
toggleCreateViewModal(type) {
const target = this.$refs['createViewModalToggle' + type][0]
this.$refs['createViewModal' + type][0].toggle(target)
},
searchAndOrder(views) {
const query = this.query

View file

@ -1,17 +1,26 @@
<template>
<li
v-tooltip="deactivated ? deactivatedText : null"
class="select__item"
:class="{
active: view._.selected,
'select__item--loading': view._.loading,
'select__item--no-options': readOnly,
disabled: deactivated,
}"
>
<a class="select__item-link" @click="$emit('selected', view)">
<a
class="select__item-link"
@click="!deactivated && $emit('selected', view)"
>
<div class="select__item-name">
<i
class="select__item-icon fas fa-fw"
:class="view._.type.colorClass + ' fa-' + view._.type.iconClass"
:class="
(deactivated ? '' : view._.type.colorClass) +
' fa-' +
view._.type.iconClass
"
></i>
<EditableViewName ref="rename" :view="view"></EditableViewName>
</div>
@ -59,6 +68,17 @@ export default {
default: true,
},
},
computed: {
deactivatedText() {
return this.$registry.get('view', this.view.type).getDeactivatedText()
},
deactivated() {
return (
!this.readOnly &&
this.$registry.get('view', this.view.type).isDeactivated()
)
},
},
methods: {
enableRename() {
this.$refs.rename.edit()

View file

@ -10,7 +10,7 @@
<i class="header__filter-icon fas fa-share-square"></i>
<span class="header__filter-name">Share form</span>
</a>
<Context ref="context" class="view-form__shared-link-context">
<Context ref="context">
<a
v-if="!view.public"
class="view-form__create-link"

View file

@ -45,6 +45,7 @@
:style="{ left: leftWidth + 'px' }"
></div>
<GridViewFieldWidthHandle
v-if="!readOnly"
class="grid-view__divider-width"
:style="{ left: leftWidth + 'px' }"
:grid="view"
@ -715,7 +716,7 @@ export default {
"deleteRow": "Delete row",
"rowCount": "No rows | 1 row | {count} rows"
}
},
},
"fr": {
"gridView":{
"insertRowAbove": "Insérer au dessus",

View file

@ -15,23 +15,26 @@
})
}}</span>
</a>
<GridViewHideContext
<ViewFieldsContext
ref="context"
:view="view"
:fields="fields"
:read-only="readOnly"
:store-prefix="storePrefix"
></GridViewHideContext>
:field-options="fieldOptions"
@update-all-field-options="updateAllFieldOptions"
@update-field-options-of-field="updateFieldOptionsOfField"
@update-order="orderFieldOptions"
></ViewFieldsContext>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import GridViewHideContext from '@baserow/modules/database/components/view/grid/GridViewHideContext'
import { notifyIf } from '@baserow/modules/core/utils/error'
import ViewFieldsContext from '@baserow/modules/database/components/view/ViewFieldsContext'
export default {
name: 'GridViewHide',
components: { GridViewHideContext },
components: { ViewFieldsContext },
props: {
fields: {
type: Array,
@ -70,6 +73,47 @@ export default {
}),
}
},
methods: {
async updateAllFieldOptions({ newFieldOptions, oldFieldOptions }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/grid/updateAllFieldOptions',
{
newFieldOptions,
oldFieldOptions,
}
)
} catch (error) {
notifyIf(error, 'view')
}
},
async updateFieldOptionsOfField({ field, values, oldValues }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/grid/updateFieldOptionsOfField',
{
field,
values,
oldValues,
}
)
} catch (error) {
notifyIf(error, 'view')
}
},
async orderFieldOptions({ order }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/grid/updateFieldOptionsOrder',
{
order,
}
)
} catch (error) {
notifyIf(error, 'view')
}
},
},
}
</script>

View file

@ -56,6 +56,19 @@ import RowEditFieldSingleSelect from '@baserow/modules/database/components/row/R
import RowEditFieldMultipleSelect from '@baserow/modules/database/components/row/RowEditFieldMultipleSelect'
import RowEditFieldPhoneNumber from '@baserow/modules/database/components/row/RowEditFieldPhoneNumber'
import RowCardFieldBoolean from '@baserow/modules/database/components/card/RowCardFieldBoolean'
import RowCardFieldDate from '@baserow/modules/database/components/card/RowCardFieldDate'
import RowCardFieldEmail from '@baserow/modules/database/components/card/RowCardFieldEmail'
import RowCardFieldFile from '@baserow/modules/database/components/card/RowCardFieldFile'
import RowCardFieldFormula from '@baserow/modules/database/components/card/RowCardFieldFormula'
import RowCardFieldLinkRow from '@baserow/modules/database/components/card/RowCardFieldLinkRow'
import RowCardFieldMultipleSelect from '@baserow/modules/database/components/card/RowCardFieldMultipleSelect'
import RowCardFieldNumber from '@baserow/modules/database/components/card/RowCardFieldNumber'
import RowCardFieldPhoneNumber from '@baserow/modules/database/components/card/RowCardFieldPhoneNumber'
import RowCardFieldSingleSelect from '@baserow/modules/database/components/card/RowCardFieldSingleSelect'
import RowCardFieldText from '@baserow/modules/database/components/card/RowCardFieldText'
import RowCardFieldURL from '@baserow/modules/database/components/card/RowCardFieldURL'
import FormViewFieldLinkRow from '@baserow/modules/database/components/view/form/FormViewFieldLinkRow'
import { trueString } from '@baserow/modules/database/utils/constants'
@ -144,6 +157,26 @@ export class FieldType extends Registerable {
return this.getRowEditFieldComponent()
}
/**
* This component should represent the field's value in a row card display. To
* improve performance, this component should be a functional component.
*/
getCardComponent() {
throw new Error(
'Not implement error. This method should return a component.'
)
}
/**
* In some cases, for example with the kanban view or the gallery view, we want to
* only show the visible cards. In order to calculate the correct position of
* those cards, we need to know the height. Because every field could have a
* different height in the card, it must be returned here.
*/
getCardValueHeight(field) {
return this.getCardComponent().height || 0
}
/**
* Because we want to show a new row immediately after creating we need to have an
* empty value to show right away.
@ -477,6 +510,10 @@ export class TextFieldType extends FieldType {
return RowEditFieldText
}
getCardComponent() {
return RowCardFieldText
}
getEmptyValue(field) {
return field.text_default
}
@ -535,6 +572,10 @@ export class LongTextFieldType extends FieldType {
return RowEditFieldLongText
}
getCardComponent() {
return RowCardFieldText
}
getEmptyValue(field) {
return ''
}
@ -601,6 +642,10 @@ export class LinkRowFieldType extends FieldType {
return FormViewFieldLinkRow
}
getCardComponent() {
return RowCardFieldLinkRow
}
getEmptyValue(field) {
return []
}
@ -711,6 +756,10 @@ export class NumberFieldType extends FieldType {
return RowEditFieldNumber
}
getCardComponent() {
return RowCardFieldNumber
}
getSortIndicator() {
return ['text', '1', '9']
}
@ -857,6 +906,10 @@ export class BooleanFieldType extends FieldType {
return RowEditFieldBoolean
}
getCardComponent() {
return RowCardFieldBoolean
}
getEmptyValue(field) {
return false
}
@ -908,6 +961,10 @@ class BaseDateFieldType extends FieldType {
return FieldDateSubForm
}
getCardComponent() {
return RowCardFieldDate
}
getSort(name, order) {
return (a, b) => {
if (a[name] === b[name]) {
@ -1179,6 +1236,10 @@ export class URLFieldType extends FieldType {
return RowEditFieldURL
}
getCardComponent() {
return RowCardFieldURL
}
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text')
return isValidURL(value) ? value : ''
@ -1252,6 +1313,10 @@ export class EmailFieldType extends FieldType {
return RowEditFieldEmail
}
getCardComponent() {
return RowCardFieldEmail
}
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text')
return isValidEmail(value) ? value : ''
@ -1328,6 +1393,10 @@ export class FileFieldType extends FieldType {
return RowEditFieldFile
}
getCardComponent() {
return RowCardFieldFile
}
getFormViewFieldComponent() {
return null
}
@ -1447,6 +1516,10 @@ export class SingleSelectFieldType extends FieldType {
return RowEditFieldSingleSelect
}
getCardComponent() {
return RowCardFieldSingleSelect
}
getSort(name, order) {
return (a, b) => {
const stringA = a[name] === null ? '' : '' + a[name].value
@ -1573,6 +1646,10 @@ export class MultipleSelectFieldType extends FieldType {
return RowEditFieldMultipleSelect
}
getCardComponent() {
return RowCardFieldMultipleSelect
}
getSort(name, order) {
return (a, b) => {
const valuesA = a[name]
@ -1707,6 +1784,10 @@ export class PhoneNumberFieldType extends FieldType {
return RowEditFieldPhoneNumber
}
getCardComponent() {
return RowCardFieldPhoneNumber
}
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text')
return isSimplePhoneNumber(value) ? value : ''
@ -1793,6 +1874,10 @@ export class FormulaFieldType extends FieldType {
return RowEditFieldFormula
}
getCardComponent() {
return RowCardFieldFormula
}
_mapFormulaTypeToFieldType(formulaType) {
return {
invalid: TextFieldType.getType(),
@ -1805,6 +1890,12 @@ export class FormulaFieldType extends FieldType {
}[formulaType]
}
getCardValueHeight(field) {
return this.app.$registry
.get('field', this._mapFormulaTypeToFieldType(field.formula_type))
.getCardValueHeight(field)
}
getSort(name, order, field, $registry) {
const underlyingFieldType = $registry.get(
'field',

View file

@ -95,6 +95,11 @@ export default {
// It might be possible that the view also has some stores that need to be
// filled with initial data so we're going to call the fetch function here.
const type = app.$registry.get('view', view.type)
if (type.isDeactivated()) {
return error({ statusCode: 400, message: type.getDeactivatedText() })
}
await type.fetch({ store }, view, data.fields, data.primary, 'page/')
} catch (e) {
// In case of a network error we want to fail hard.

View file

@ -61,6 +61,7 @@ import viewStore from '@baserow/modules/database/store/view'
import fieldStore from '@baserow/modules/database/store/field'
import gridStore from '@baserow/modules/database/store/view/grid'
import formStore from '@baserow/modules/database/store/view/form'
import rowModal from '@baserow/modules/database/store/rowModal'
import { registerRealtimeEvents } from '@baserow/modules/database/realtime'
import { CSVTableExporterType } from '@baserow/modules/database/exporterTypes'
@ -104,6 +105,7 @@ export default (context) => {
store.registerModule('table', tableStore)
store.registerModule('view', viewStore)
store.registerModule('field', fieldStore)
store.registerModule('rowModal', rowModal)
store.registerModule('page/view/grid', gridStore)
store.registerModule('page/view/form', formStore)
store.registerModule('template/view/grid', gridStore)

View file

@ -152,10 +152,10 @@ export const registerRealtimeEvents = (realtime) => {
}
})
realtime.registerEvent('row_updated', (context, data) => {
realtime.registerEvent('row_updated', async (context, data) => {
const { app, store } = context
for (const viewType of Object.values(app.$registry.getAll('view'))) {
viewType.rowUpdated(
await viewType.rowUpdated(
context,
data.table_id,
store.getters['field/getAll'],
@ -166,6 +166,8 @@ export const registerRealtimeEvents = (realtime) => {
'page/'
)
}
store.dispatch('rowModal/updated', { values: data.row })
})
realtime.registerEvent('row_deleted', (context, data) => {
@ -188,20 +190,25 @@ export const registerRealtimeEvents = (realtime) => {
}
})
realtime.registerEvent('view_updated', ({ store, app }, data) => {
realtime.registerEvent('view_updated', (context, data) => {
const { store, app } = context
const view = store.getters['view/get'](data.view.id)
if (view !== undefined) {
const filterType = view.filter_type
const filtersDisabled = view.filters_disabled
const oldView = clone(view)
store.dispatch('view/forceUpdate', { view, values: data.view })
if (
store.getters['view/getSelectedId'] === view.id &&
(filterType !== data.view.filter_type ||
filtersDisabled !== data.view.filters_disabled)
) {
app.$bus.$emit('table-refresh', {
tableId: store.getters['table/getSelectedId'],
})
if (view.id === store.getters['view/getSelectedId']) {
const viewType = app.$registry.get('view', view.type)
const refresh = viewType.updated(context, view, oldView, 'page/')
if (
refresh ||
view.filter_type !== oldView.filter_type ||
view.filters_disabled !== oldView.filters_disabled
) {
app.$bus.$emit('table-refresh', {
tableId: store.getters['table/getSelectedId'],
})
}
}
}
})
@ -323,7 +330,7 @@ export const registerRealtimeEvents = (realtime) => {
realtime.registerEvent('view_field_options_updated', (context, data) => {
const { store, app } = context
const view = store.getters['view/get'](data.view_id)
if (view !== null && view.id === store.getters['view/getSelectedId']) {
if (view !== undefined && view.id === store.getters['view/getSelectedId']) {
const viewType = app.$registry.get('view', view.type)
viewType.fieldOptionsUpdated(context, view, data.field_options, 'page/')
}

View file

@ -0,0 +1,77 @@
import Vue from 'vue'
/**
* This store exists to always keep a copy of the row that's being edited via the
* row edit modal. It sometimes happen that row from the original source, where it was
* reactive with doesn't exist anymore. To make sure the modal still works in that
* case, we always store a copy here and if it doesn't exist in the original data
* source it accepts real time updates.
*/
export const state = () => ({
id: -1,
exists: false,
row: {},
})
export const mutations = {
CLEAR(state) {
state.id = -1
state.exists = false
state.row = {}
},
OPEN(state, { id, exists, row }) {
state.id = id
state.exists = exists
state.row = row
},
SET_EXISTS(state, value) {
state.exists = value
},
REPLACE_ROW(state, row) {
Vue.set(state, 'row', row)
},
UPDATE_ROW(state, row) {
Object.assign(state.row, row)
},
}
export const actions = {
clear({ commit }) {
commit('CLEAR')
},
open({ commit }, { id, exists, row }) {
commit('OPEN', { id, exists, row })
},
doesNotExist({ commit }) {
commit('SET_EXISTS', false)
},
doesExist({ commit }, { row }) {
commit('SET_EXISTS', true)
commit('REPLACE_ROW', row)
},
updated({ commit, getters }, { values }) {
if (values.id === getters.id && !getters.exists) {
commit('UPDATE_ROW', values)
}
},
}
export const getters = {
id: (state) => {
return state.id
},
exists: (state) => {
return state.exists
},
row: (state) => {
return state.row
},
}
export default {
namespaced: true,
state,
getters,
actions,
mutations,
}

View file

@ -103,6 +103,21 @@ export class ViewType extends Registerable {
return view
}
/**
* Indicates whether of nor the provided table id has this current view type
* selected. Because every view type has it's own store, this check can be used to
* check if the state of that store has to be updated.
*/
isCurrentView(store, tableId) {
const table = store.getters['table/getSelected']
const view = store.getters['view/getSelected']
return (
table.id === tableId &&
Object.prototype.hasOwnProperty.call(view, 'type') &&
view.type === this.getType()
)
}
/**
* The fetch method is called inside the asyncData function when the table page
* loads with a selected view. It is possible to fill some stores serverside here.
@ -116,7 +131,14 @@ export class ViewType extends Registerable {
* stay the same if possible. Can throw a RefreshCancelledException when the view
* wishes to cancel the current refresh call due to a new refresh call.
*/
refresh({ store }, view) {}
refresh(
context,
view,
fields,
primary,
storePrefix = '',
includeFieldOptions = false
) {}
/**
* Method that is called when a field has been created. This can be useful to
@ -147,6 +169,16 @@ export class ViewType extends Registerable {
*/
fieldOptionsUpdated(context, view, fieldOptions, storePrefix) {}
/**
* Method that is called when the selected view is updated.
*
* @return Indicates whether the page should be refreshed. The view is already
* refreshed automatically when the filter type or disabled is updated.
*/
updated(context, view, oldView, storePrefix) {
return false
}
/**
* Event that is called when a row is created from an outside source, so for example
* via a real time event by another user. It can be used to check if data in an store
@ -213,6 +245,18 @@ export class ViewType extends Registerable {
canSort: this.canSort,
}
}
/**
* If the view type is disabled, this text will be visible explaining why.
*/
getDeactivatedText() {}
/**
* Indicates if the view type is disabled.
*/
isDeactivated() {
return false
}
}
export class GridViewType extends ViewType {
@ -340,16 +384,6 @@ export class GridViewType extends ViewType {
)
}
isCurrentView(store, tableId) {
const table = store.getters['table/getSelected']
const grid = store.getters['view/getSelected']
return (
table.id === tableId &&
Object.prototype.hasOwnProperty.call(grid, 'type') &&
grid.type === GridViewType.getType()
)
}
async rowCreated(
{ store },
tableId,
@ -426,7 +460,7 @@ export class GridViewType extends ViewType {
updateFunction,
storePrefix = ''
) {
if (this.isCurrentView(store, tableId, storePrefix)) {
if (this.isCurrentView(store, tableId)) {
await store.dispatch(storePrefix + 'view/grid/updateRowMetadata', {
tableId,
rowId,