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:
parent
931a88a195
commit
fd246a3e74
98 changed files with 6229 additions and 166 deletions
backend/src/baserow
config/settings
contrib/database
premium
backend
web-frontend
modules/baserow_premium
applicationTypes.js
assets/scss/components
components/views/kanban
KanbanView.vueKanbanViewCreateStackContext.vueKanbanViewHeader.vueKanbanViewOptionForm.vueKanbanViewStack.vueKanbanViewStackContext.vueKanbanViewStackedBy.vueKanbanViewUpdateStackContext.vue
mixins
plugin.jsservices/views
store/view
viewTypes.jstest/unit/premium/store/view
web-frontend/modules
core
assets/scss
components
all.scsscard.scss
helpers.scsscard
all.scssboolean.scssfile.scsslink_row.scssmany_to_many.scssmultiple_select.scsssingle_select.scsstext.scss
choice_items.scsscontext.scssform.scsshidings.scssselect.scsscomponents
directives
pages
plugins
database
applicationTypes.jsfieldTypes.js
components
card
RowCard.vueRowCardFieldBoolean.vueRowCardFieldDate.vueRowCardFieldEmail.vueRowCardFieldFile.vueRowCardFieldFormula.vueRowCardFieldLinkRow.vueRowCardFieldMultipleSelect.vueRowCardFieldNumber.vueRowCardFieldPhoneNumber.vueRowCardFieldSingleSelect.vueRowCardFieldText.vueRowCardFieldURL.vue
export
field
row
view
pages
plugin.jsrealtime.jsstore
viewTypes.js
|
@ -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"},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.",
|
||||
)
|
|
@ -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)
|
|
@ -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())
|
10
premium/backend/src/baserow_premium/api/views/kanban/urls.py
Normal file
10
premium/backend/src/baserow_premium/api/views/kanban/urls.py
Normal 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"),
|
||||
]
|
194
premium/backend/src/baserow_premium/api/views/kanban/views.py
Normal file
194
premium/backend/src/baserow_premium/api/views/kanban/views.py
Normal 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)
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
0
premium/backend/src/baserow_premium/views/__init__.py
Normal file
0
premium/backend/src/baserow_premium/views/__init__.py
Normal file
11
premium/backend/src/baserow_premium/views/exceptions.py
Normal file
11
premium/backend/src/baserow_premium/views/exceptions.py
Normal 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.
|
||||
"""
|
117
premium/backend/src/baserow_premium/views/handler.py
Normal file
117
premium/backend/src/baserow_premium/views/handler.py
Normal 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
|
43
premium/backend/src/baserow_premium/views/models.py
Normal file
43
premium/backend/src/baserow_premium/views/models.py
Normal 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",
|
||||
)
|
140
premium/backend/src/baserow_premium/views/view_types.py
Normal file
140
premium/backend/src/baserow_premium/views/view_types.py
Normal 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
|
||||
)
|
|
@ -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
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
|
@ -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]
|
|
@ -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() {
|
||||
|
|
|
@ -6,3 +6,4 @@
|
|||
@import 'expandable_textarea';
|
||||
@import 'licenses';
|
||||
@import 'license_detail';
|
||||
@import 'views/kanban';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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',
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
205
premium/web-frontend/modules/baserow_premium/viewTypes.js
Normal file
205
premium/web-frontend/modules/baserow_premium/viewTypes.js
Normal 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)
|
||||
}
|
||||
}
|
298
premium/web-frontend/test/unit/premium/store/view/kanban.spec.js
Normal file
298
premium/web-frontend/test/unit/premium/store/view/kanban.spec.js
Normal 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)
|
||||
})
|
||||
})
|
|
@ -74,5 +74,7 @@
|
|||
@import 'infinite_scroll';
|
||||
@import 'formula_field';
|
||||
@import 'lang_picker';
|
||||
@import 'card';
|
||||
@import 'card/all';
|
||||
@import 'webhook';
|
||||
@import 'tab';
|
||||
|
|
31
web-frontend/modules/core/assets/scss/components/card.scss
Normal file
31
web-frontend/modules/core/assets/scss/components/card.scss
Normal 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%;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
@import 'text';
|
||||
@import 'boolean';
|
||||
@import 'many_to_many';
|
||||
@import 'link_row';
|
||||
@import 'single_select';
|
||||
@import 'multiple_select';
|
||||
@import 'file';
|
|
@ -0,0 +1,6 @@
|
|||
.card-boolean {
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
height: 13px;
|
||||
color: $color-success-500;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.card-multiple-select-option {
|
||||
overflow: visible;
|
||||
|
||||
@include select-option-style(flex);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.card-single-select-option__wrapper {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.card-single-select-option {
|
||||
@extend %ellipsis;
|
||||
|
||||
@include select-option-style(inline-block);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.card-text {
|
||||
@extend %ellipsis;
|
||||
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
min-width: 1px;
|
||||
line-height: 16px;
|
||||
height: 16px;
|
||||
}
|
|
@ -61,7 +61,7 @@
|
|||
}
|
||||
|
||||
&.deactivated {
|
||||
background-color: $color-neutral-100;
|
||||
background-color: $color-neutral-50;
|
||||
color: $color-neutral-400;
|
||||
|
||||
&:hover {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
110
web-frontend/modules/core/directives/autoScroll.js
Normal file
110
web-frontend/modules/core/directives/autoScroll.js
Normal 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)
|
||||
},
|
||||
}
|
|
@ -52,7 +52,7 @@ export default {
|
|||
: el
|
||||
|
||||
el.mousedownEvent = (event) => {
|
||||
if (!el.sortableEnabled) {
|
||||
if (!el.sortableEnabled || event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
35
web-frontend/modules/database/components/card/RowCard.vue
Normal file
35
web-frontend/modules/database/components/card/RowCard.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,11 @@
|
|||
<template functional>
|
||||
<div class="card-text">
|
||||
{{ props.value }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
height: 16,
|
||||
}
|
||||
</script>
|
|
@ -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>
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 || '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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')
|
||||
|
|
133
web-frontend/modules/database/components/row/RowCreateModal.vue
Normal file
133
web-frontend/modules/database/components/row/RowCreateModal.vue
Normal 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>
|
|
@ -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)
|
||||
},
|
||||
/**
|
||||
|
|
|
@ -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>
|
|
@ -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(
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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/')
|
||||
}
|
||||
|
|
77
web-frontend/modules/database/store/rowModal.js
Normal file
77
web-frontend/modules/database/store/rowModal.js
Normal 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,
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue