1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-06 22:08:52 +00:00

Merge branch '2583-repeat-element-add-current_record-data-provider-support' into 'develop'

Resolve "Repeat element: add current_record data provider support"

Closes 

See merge request 
This commit is contained in:
Peter Evans 2024-05-24 10:35:59 +00:00
commit 4b38d3a49d
27 changed files with 664 additions and 81 deletions

View file

@ -391,7 +391,9 @@ class DispatchBuilderWorkflowActionView(APIView):
workflow_action_id workflow_action_id
) )
dispatch_context = BuilderDispatchContext(request, workflow_action.page) dispatch_context = BuilderDispatchContext(
request, workflow_action.page, workflow_action=workflow_action
)
response = BuilderWorkflowActionService().dispatch_action( response = BuilderWorkflowActionService().dispatch_action(
request.user, workflow_action, dispatch_context # type: ignore request.user, workflow_action, dispatch_context # type: ignore

View file

@ -13,7 +13,10 @@ from baserow.contrib.builder.data_sources.exceptions import (
) )
from baserow.contrib.builder.data_sources.handler import DataSourceHandler from baserow.contrib.builder.data_sources.handler import DataSourceHandler
from baserow.contrib.builder.elements.handler import ElementHandler from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.mixins import FormElementTypeMixin from baserow.contrib.builder.elements.mixins import (
CollectionElementTypeMixin,
FormElementTypeMixin,
)
from baserow.contrib.builder.elements.models import FormElement from baserow.contrib.builder.elements.models import FormElement
from baserow.contrib.builder.workflow_actions.handler import ( from baserow.contrib.builder.workflow_actions.handler import (
BuilderWorkflowActionHandler, BuilderWorkflowActionHandler,
@ -185,9 +188,35 @@ class CurrentRecordDataProviderType(DataProviderType):
type = "current_record" type = "current_record"
def get_data_chunk(self, dispatch_context: BuilderDispatchContext, path: List[str]): def get_data_chunk(self, dispatch_context: BuilderDispatchContext, path: List[str]):
"""Doesn't make sense in the backend yet""" """
Get the current record data from the request data.
return None :param dispatch_context: The dispatch context.
:param path: The path to the data.
:return: The data at the path.
"""
try:
current_record = dispatch_context.request.data["current_record"]
except KeyError:
return None
first_collection_element_ancestor = ElementHandler().get_first_ancestor_of_type(
dispatch_context.workflow_action.element_id,
CollectionElementTypeMixin,
)
data_source_id = first_collection_element_ancestor.specific.data_source_id
# Narrow down our range to just our record index.
dispatch_context = BuilderDispatchContext.from_context(
dispatch_context,
offset=current_record,
count=1,
)
return DataSourceDataProviderType().get_data_chunk(
dispatch_context, [data_source_id, "0", *path]
)
def import_path(self, path, id_mapping, data_source_id=None, **kwargs): def import_path(self, path, id_mapping, data_source_id=None, **kwargs):
""" """

View file

@ -1,3 +1,5 @@
from typing import TYPE_CHECKING, Optional
from rest_framework.request import Request from rest_framework.request import Request
from baserow.contrib.builder.data_providers.registries import ( from baserow.contrib.builder.data_providers.registries import (
@ -6,11 +8,28 @@ from baserow.contrib.builder.data_providers.registries import (
from baserow.contrib.builder.pages.models import Page from baserow.contrib.builder.pages.models import Page
from baserow.core.services.dispatch_context import DispatchContext from baserow.core.services.dispatch_context import DispatchContext
if TYPE_CHECKING:
from baserow.core.workflow_actions.models import WorkflowAction
class BuilderDispatchContext(DispatchContext): class BuilderDispatchContext(DispatchContext):
def __init__(self, request: Request, page: Page): own_properties = ["request", "page", "workflow_action", "offset", "count"]
def __init__(
self,
request: Request,
page: Page,
workflow_action: Optional["WorkflowAction"] = None,
offset: Optional[int] = None,
count: Optional[int] = None,
):
self.request = request self.request = request
self.page = page self.page = page
self.workflow_action = workflow_action
# Overrides the `request` GET offset/count values.
self.offset = offset
self.count = count
super().__init__() super().__init__()
@ -19,17 +38,24 @@ class BuilderDispatchContext(DispatchContext):
return builder_data_provider_type_registry return builder_data_provider_type_registry
def range(self, service): def range(self, service):
"""Return page range from the GET parameters.""" """
Return page range from the `offset`, `count` kwargs,
or the GET parameters.
"""
try: if self.offset is not None and self.count is not None:
offset = int(self.request.GET.get("offset", 0)) offset = self.offset
except ValueError: count = self.count
offset = 0 else:
try:
offset = int(self.request.GET.get("offset", 0))
except ValueError:
offset = 0
try: try:
count = int(self.request.GET.get("count", 20)) count = int(self.request.GET.get("count", 20))
except ValueError: except ValueError:
count = 20 count = 20
# max prevent negative values # max prevent negative values
return [ return [

View file

@ -1,5 +1,5 @@
from collections import defaultdict from collections import defaultdict
from typing import Any, Dict, Iterable, List, Optional, Union, cast from typing import Any, Dict, Iterable, List, Optional, Type, Union, cast
from zipfile import ZipFile from zipfile import ZipFile
from django.core.files.storage import Storage from django.core.files.storage import Storage
@ -12,17 +12,22 @@ from baserow.contrib.builder.elements.exceptions import (
from baserow.contrib.builder.elements.models import ContainerElement, Element from baserow.contrib.builder.elements.models import ContainerElement, Element
from baserow.contrib.builder.elements.registries import ( from baserow.contrib.builder.elements.registries import (
ElementType, ElementType,
ElementTypeSubClass,
element_type_registry, element_type_registry,
) )
from baserow.contrib.builder.elements.types import (
ElementForUpdate,
ElementsAndWorkflowActions,
)
from baserow.contrib.builder.pages.models import Page from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.workflow_actions.models import BuilderWorkflowAction
from baserow.contrib.builder.workflow_actions.registries import (
builder_workflow_action_type_registry,
)
from baserow.core.db import specific_iterator from baserow.core.db import specific_iterator
from baserow.core.exceptions import IdDoesNotExist from baserow.core.exceptions import IdDoesNotExist
from baserow.core.utils import MirrorDict, extract_allowed from baserow.core.utils import MirrorDict, extract_allowed
from ..workflow_actions.models import BuilderWorkflowAction
from ..workflow_actions.registries import builder_workflow_action_type_registry
from .types import ElementForUpdate, ElementsAndWorkflowActions
class ElementHandler: class ElementHandler:
allowed_fields_create = [ allowed_fields_create = [
@ -94,6 +99,47 @@ class ElementHandler:
return element return element
def get_ancestors(self, element_id: int, page: Page) -> List[Element]:
"""
Returns a list of all the ancestors of the given element. The ancestry
results are cached in the dispatch context to avoid multiple queries in
the same HTTP request.
:param element_id: The ID of the element.
:param page: The page that holds the elements.
:return: A list of the ancestors of the given element.
"""
elements = self.get_elements(page)
grouped_elements = {element.id: element for element in elements}
element = grouped_elements[element_id]
ancestry = []
while element.parent_element_id is not None:
element = grouped_elements[element.parent_element_id]
ancestry.append(element)
return ancestry
def get_first_ancestor_of_type(
self,
element_id: int,
target_type: Type[ElementTypeSubClass],
) -> Optional[Element]:
"""
Returns the first ancestor of the given type belonging to this element.
:param element_id: The ID of the element.
:param target_type: The type of the element to find.
:return: The first ancestor of the given type or None if not found.
"""
element = ElementHandler().get_element(element_id)
for ancestor in self.get_ancestors(element_id, element.page):
ancestor_type = element_type_registry.get_by_model(ancestor.specific_class)
if isinstance(ancestor_type, target_type):
return ancestor
def get_element_for_update( def get_element_for_update(
self, element_id: int, base_queryset: Optional[QuerySet] = None self, element_id: int, base_queryset: Optional[QuerySet] = None
) -> ElementForUpdate: ) -> ElementForUpdate:
@ -120,6 +166,7 @@ class ElementHandler:
page: Page, page: Page,
base_queryset: Optional[QuerySet] = None, base_queryset: Optional[QuerySet] = None,
specific: bool = True, specific: bool = True,
use_cache: bool = True,
) -> Union[QuerySet[Element], Iterable[Element]]: ) -> Union[QuerySet[Element], Iterable[Element]]:
""" """
Gets all the specific elements of a given page. Gets all the specific elements of a given page.
@ -128,18 +175,38 @@ class ElementHandler:
:param base_queryset: The base queryset to use to build the query. :param base_queryset: The base queryset to use to build the query.
:param specific: Whether to return the generic elements or the specific :param specific: Whether to return the generic elements or the specific
instances. instances.
:param use_cache: Whether to use the cached elements on the page or not.
:return: The elements of that page. :return: The elements of that page.
""" """
# Our cache key varies if we're using specific or generic elements.
cache_key = "_page_elements" if not specific else "_page_elements_specific"
# If a `base_queryset` is given, we can't use caching, clear
# both cache keys in case the specific argument has changed.
if base_queryset is not None:
use_cache = False
setattr(page, "_page_elements", None)
setattr(page, "_page_elements_specific", None)
elements_cache = getattr(page, cache_key, None)
if use_cache and elements_cache is not None:
return elements_cache
queryset = base_queryset if base_queryset is not None else Element.objects.all() queryset = base_queryset if base_queryset is not None else Element.objects.all()
queryset = queryset.filter(page=page) queryset = queryset.filter(page=page)
if specific: if specific:
queryset = queryset.select_related("content_type") queryset = queryset.select_related("content_type")
return specific_iterator(queryset) elements = specific_iterator(queryset)
else: else:
return queryset elements = queryset
if use_cache:
setattr(page, cache_key, list(elements))
return elements
def create_element( def create_element(
self, self,

View file

@ -2,9 +2,12 @@ from abc import ABC, abstractmethod
from baserow.core.formula.runtime_formula_context import RuntimeFormulaContext from baserow.core.formula.runtime_formula_context import RuntimeFormulaContext
from baserow.core.services.models import Service from baserow.core.services.models import Service
from baserow.core.services.types import RuntimeFormulaContextSubClass
class DispatchContext(RuntimeFormulaContext, ABC): class DispatchContext(RuntimeFormulaContext, ABC):
own_properties = []
def __init__(self): def __init__(self):
self.cache = {} # can be used by data providers to save queries self.cache = {} # can be used by data providers to save queries
super().__init__() super().__init__()
@ -16,3 +19,24 @@ class DispatchContext(RuntimeFormulaContext, ABC):
:params service: The service we want the pagination for. :params service: The service we want the pagination for.
""" """
@classmethod
def from_context(
cls, context: RuntimeFormulaContextSubClass, **kwargs
) -> RuntimeFormulaContextSubClass:
"""
Return a new DispatchContext instance from the given context, without
losing the original cached data.
:params context: The context to create a new DispatchContext instance from.
"""
new_values = {}
for prop in cls.own_properties:
new_values[prop] = getattr(context, prop)
new_values.update(kwargs)
new_context = cls(**new_values)
new_context.cache = {**context.cache}
return new_context

View file

@ -1,6 +1,7 @@
from typing import NewType, Optional, TypedDict, TypeVar from typing import NewType, Optional, TypedDict, TypeVar
from .models import Service from baserow.core.formula.runtime_formula_context import RuntimeFormulaContext
from baserow.core.services.models import Service
class ServiceDict(TypedDict): class ServiceDict(TypedDict):
@ -34,3 +35,7 @@ ServiceSortDictSubClass = TypeVar("ServiceSortDictSubClass", bound="ServiceSortD
ServiceSubClass = TypeVar("ServiceSubClass", bound="Service") ServiceSubClass = TypeVar("ServiceSubClass", bound="Service")
ServiceForUpdate = NewType("ServiceForUpdate", Service) ServiceForUpdate = NewType("ServiceForUpdate", Service)
RuntimeFormulaContextSubClass = TypeVar(
"RuntimeFormulaContextSubClass", bound=RuntimeFormulaContext
)

View file

@ -35,10 +35,10 @@ class DataSourceFixtures:
service = kwargs.pop("service", None) service = kwargs.pop("service", None)
if service is None and service_model_class: if service is None and service_model_class:
integrations_args = kwargs.pop("integration_args", {})
integrations_args["application"] = page.builder
service = self.create_service( service = self.create_service(
service_model_class, service_model_class, integration_args=integrations_args, **kwargs
integration_args={"application": page.builder},
**kwargs,
) )
if order is None: if order is None:

View file

@ -10,6 +10,7 @@ from baserow.contrib.builder.elements.models import (
ImageElement, ImageElement,
InputTextElement, InputTextElement,
LinkElement, LinkElement,
RepeatElement,
TableElement, TableElement,
TextElement, TextElement,
) )
@ -96,6 +97,14 @@ class ElementFixtures:
element = self.create_builder_element(DropdownElement, user, page, **kwargs) element = self.create_builder_element(DropdownElement, user, page, **kwargs)
return element return element
def create_builder_repeat_element(self, user=None, page=None, **kwargs):
if "data_source" not in kwargs:
kwargs[
"data_source"
] = self.create_builder_local_baserow_list_rows_data_source(page=page)
element = self.create_builder_element(RepeatElement, user, page, **kwargs)
return element
def create_builder_element(self, model_class, user=None, page=None, **kwargs): def create_builder_element(self, model_class, user=None, page=None, **kwargs):
if user is None: if user is None:
user = self.create_user() user = self.create_user()

View file

@ -22,6 +22,7 @@ from baserow.contrib.builder.data_sources.builder_dispatch_context import (
) )
from baserow.contrib.builder.elements.handler import ElementHandler from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.formula_importer import import_formula from baserow.contrib.builder.formula_importer import import_formula
from baserow.contrib.builder.workflow_actions.models import EventTypes
from baserow.core.services.dispatch_context import DispatchContext from baserow.core.services.dispatch_context import DispatchContext
from baserow.core.services.exceptions import ServiceImproperlyConfigured from baserow.core.services.exceptions import ServiceImproperlyConfigured
from baserow.core.user_sources.user_source_user import UserSourceUser from baserow.core.user_sources.user_source_user import UserSourceUser
@ -759,6 +760,72 @@ def test_user_data_provider_get_data_chunk(data_fixture):
assert user_data_provider_type.get_data_chunk(dispatch_context, ["id"]) == 42 assert user_data_provider_type.get_data_chunk(dispatch_context, ["id"]) == 42
@pytest.mark.django_db
def test_current_record_provider_get_data_chunk_without_record_index(data_fixture):
current_record_provider = CurrentRecordDataProviderType()
user, token = data_fixture.create_user_and_token()
fake_request = MagicMock()
fake_request.data = {}
builder = data_fixture.create_builder_application(user=user)
page = data_fixture.create_builder_page(user=user, builder=builder)
workflow_action = data_fixture.create_local_baserow_create_row_workflow_action(
page=page, event=EventTypes.CLICK, user=user
)
dispatch_context = BuilderDispatchContext(fake_request, page, workflow_action)
assert current_record_provider.get_data_chunk(dispatch_context, ["path"]) is None
@pytest.mark.django_db
def test_current_record_provider_get_data_chunk(data_fixture):
current_record_provider = CurrentRecordDataProviderType()
user, token = data_fixture.create_user_and_token()
fake_request = MagicMock()
fake_request.user = user
fake_request.data = {"current_record": 0}
table, fields, rows = data_fixture.build_table(
user=user,
columns=[
("Animal", "text"),
],
rows=[
["Badger"],
["Horse"],
["Bison"],
],
)
field = table.field_set.get(name="Animal")
builder = data_fixture.create_builder_application(user=user)
page = data_fixture.create_builder_page(user=user, builder=builder)
data_source = data_fixture.create_builder_local_baserow_list_rows_data_source(
page=page, table=table, integration_args={"authorized_user": user}
)
repeat_element = data_fixture.create_builder_repeat_element(
page=page, data_source=data_source
)
button_element = data_fixture.create_builder_button_element(
page=page, parent_element=repeat_element
)
workflow_action = data_fixture.create_local_baserow_create_row_workflow_action(
page=page, element=button_element, event=EventTypes.CLICK, user=user
)
dispatch_context = BuilderDispatchContext(fake_request, page, workflow_action)
assert (
current_record_provider.get_data_chunk(dispatch_context, [field.db_column])
== "Badger"
)
@pytest.mark.django_db @pytest.mark.django_db
def test_current_record_provider_type_import_path(data_fixture): def test_current_record_provider_type_import_path(data_fixture):
# When a `current_record` provider is imported, and the path only contains the # When a `current_record` provider is imported, and the path only contains the

View file

@ -1,5 +1,10 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
from django.http import HttpRequest
import pytest
from rest_framework.request import Request
from baserow.contrib.builder.data_sources.builder_dispatch_context import ( from baserow.contrib.builder.data_sources.builder_dispatch_context import (
BuilderDispatchContext, BuilderDispatchContext,
) )
@ -24,3 +29,24 @@ def test_dispatch_context_page_range():
dispatch_context = BuilderDispatchContext(request, None) dispatch_context = BuilderDispatchContext(request, None)
assert dispatch_context.range(None) == [0, 1] assert dispatch_context.range(None) == [0, 1]
@pytest.mark.django_db
def test_dispatch_context_page_from_context(data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
request = Request(HttpRequest())
request.user = user
dispatch_context = BuilderDispatchContext(request, page, offset=0, count=5)
dispatch_context.annotated_data = "foobar"
dispatch_context.cache = {"key": "value"}
new_dispatch_context = BuilderDispatchContext.from_context(
dispatch_context, offset=5, count=1
)
assert getattr(new_dispatch_context, "annotated_data", None) is None
assert new_dispatch_context.cache == {"key": "value"}
assert new_dispatch_context.request == request
assert new_dispatch_context.page == page
assert new_dispatch_context.offset == 5
assert new_dispatch_context.count == 1

View file

@ -54,13 +54,17 @@ def test_get_element_does_not_exist(data_fixture):
@pytest.mark.django_db @pytest.mark.django_db
def test_get_elements(data_fixture): def test_get_elements(data_fixture, django_assert_num_queries):
page = data_fixture.create_builder_page() page = data_fixture.create_builder_page()
element1 = data_fixture.create_builder_heading_element(page=page) element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page) element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_text_element(page=page) element3 = data_fixture.create_builder_text_element(page=page)
elements = ElementHandler().get_elements(page) with django_assert_num_queries(3):
elements = ElementHandler().get_elements(page)
# Cache of specific elements is set.
assert getattr(page, "_page_elements_specific") == elements
assert [e.id for e in elements] == [ assert [e.id for e in elements] == [
element1.id, element1.id,
@ -69,8 +73,31 @@ def test_get_elements(data_fixture):
] ]
assert isinstance(elements[0], HeadingElement) assert isinstance(elements[0], HeadingElement)
assert isinstance(elements[1], HeadingElement)
assert isinstance(elements[2], TextElement) assert isinstance(elements[2], TextElement)
# Cache of specific elements is re-used.
with django_assert_num_queries(0):
elements = ElementHandler().get_elements(page)
assert getattr(page, "_page_elements_specific") == elements
# We request non-specific records, the cache changes.
with django_assert_num_queries(1):
elements = list(ElementHandler().get_elements(page, specific=False))
assert getattr(page, "_page_elements") == elements
# We request non-specific records, the cache is reused.
with django_assert_num_queries(0):
elements = list(ElementHandler().get_elements(page, specific=False))
assert getattr(page, "_page_elements") == elements
# We pass in a base queryset, no caching strategy is available.
base_queryset = Element.objects.filter(page=page, visibility="all")
with django_assert_num_queries(3):
ElementHandler().get_elements(page, base_queryset)
assert getattr(page, "_page_elements") is None
assert getattr(page, "_page_elements_specific") is None
@pytest.mark.django_db @pytest.mark.django_db
def test_delete_element(data_fixture): def test_delete_element(data_fixture):
@ -505,3 +532,44 @@ def test_duplicate_element_with_workflow_action_in_container(data_fixture):
] ]
assert duplicated_workflow_action1.page_id == workflow_action1.page_id assert duplicated_workflow_action1.page_id == workflow_action1.page_id
assert duplicated_workflow_action2.page_id == workflow_action2.page_id assert duplicated_workflow_action2.page_id == workflow_action2.page_id
@pytest.mark.django_db
def test_get_ancestors(data_fixture, django_assert_num_queries):
page = data_fixture.create_builder_page()
grandparent = data_fixture.create_builder_column_element(column_amount=1, page=page)
parent = data_fixture.create_builder_column_element(
column_amount=3, parent_element=grandparent, page=page
)
child = data_fixture.create_builder_heading_element(
page=page, parent_element=parent
)
# Query and cache the page's elements for the same context.
# Query 1: fetch the elements on the page.
# 2: fetch the specific column types.
# 3: fetch the specific heading type.
with django_assert_num_queries(3):
ancestors = ElementHandler().get_ancestors(child.id, page)
assert len(ancestors) == 2
assert ancestors == [parent, grandparent]
@pytest.mark.django_db
def test_get_first_ancestor_of_type(data_fixture, django_assert_num_queries):
page = data_fixture.create_builder_page()
grandparent = data_fixture.create_builder_column_element(column_amount=1, page=page)
parent = data_fixture.create_builder_form_container_element(
parent_element=grandparent, page=page
)
child = data_fixture.create_builder_dropdown_element(
page=page, parent_element=parent
)
with django_assert_num_queries(7):
nearest_column_ancestor = ElementHandler().get_first_ancestor_of_type(
child.id, ColumnElementType
)
assert nearest_column_ancestor.specific == grandparent

View file

@ -22,6 +22,7 @@
<script> <script>
import FormulaInputGroup from '@baserow/modules/core/components/formula/FormulaInputGroup' import FormulaInputGroup from '@baserow/modules/core/components/formula/FormulaInputGroup'
import { DataSourceDataProviderType } from '@baserow/modules/builder/dataProviderTypes' import { DataSourceDataProviderType } from '@baserow/modules/builder/dataProviderTypes'
export default { export default {
name: 'ApplicationBuilderFormulaInputGroup', name: 'ApplicationBuilderFormulaInputGroup',
components: { FormulaInputGroup }, components: { FormulaInputGroup },

View file

@ -34,7 +34,12 @@
@select-parent="selectParentElement()" @select-parent="selectParentElement()"
/> />
<PageElement :element="element" :mode="mode" class="element--read-only" /> <PageElement
:element="element"
:mode="mode"
class="element--read-only"
:application-context-additions="applicationContextAdditions"
/>
<InsertElementButton <InsertElementButton
v-show="isSelected" v-show="isSelected"
@ -95,6 +100,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
applicationContextAdditions: {
type: Object,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
@ -144,7 +154,10 @@ export default {
return elementType.getPlacementsDisabled(this.page, this.element) return elementType.getPlacementsDisabled(this.page, this.element)
}, },
elementTypesAllowed() { elementTypesAllowed() {
return this.parentElementType?.childElementTypes || null return (
this.parentElementType?.childElementTypes(this.page, this.element) ||
null
)
}, },
canCreate() { canCreate() {
return this.$hasPermission( return this.$hasPermission(

View file

@ -21,12 +21,14 @@
<ElementPreview <ElementPreview
v-if="mode === 'editing'" v-if="mode === 'editing'"
:element="childCurrent" :element="childCurrent"
:application-context-additions="applicationContextAdditions"
@move="move(childCurrent, $event)" @move="move(childCurrent, $event)"
></ElementPreview> ></ElementPreview>
<PageElement <PageElement
v-else v-else
:element="childCurrent" :element="childCurrent"
:mode="mode" :mode="mode"
:application-context-additions="applicationContextAdditions"
></PageElement> ></PageElement>
</div> </div>
</template> </template>
@ -41,7 +43,7 @@
<AddElementModal <AddElementModal
ref="addElementModal" ref="addElementModal"
:page="page" :page="page"
:element-types-allowed="elementType.childElementTypes" :element-types-allowed="elementType.childElementTypes(page, element)"
/> />
</div> </div>
</template> </template>
@ -79,6 +81,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
applicationContextAdditions: {
type: Object,
required: false,
default: null,
},
}, },
computed: { computed: {
flexAlignment() { flexAlignment() {

View file

@ -15,7 +15,7 @@
<AddElementModal <AddElementModal
ref="addElementModal" ref="addElementModal"
:page="page" :page="page"
:element-types-allowed="elementType.childElementTypes" :element-types-allowed="elementType.childElementTypes(page, element)"
></AddElementModal> ></AddElementModal>
</div> </div>
<div v-else> <div v-else>

View file

@ -13,6 +13,9 @@
v-if="index === 0 && isEditMode" v-if="index === 0 && isEditMode"
:key="child.id" :key="child.id"
:element="child" :element="child"
:application-context-additions="{
recordIndex: index,
}"
@move="moveElement(child, $event)" @move="moveElement(child, $event)"
/> />
<!-- Other iterations are not editable --> <!-- Other iterations are not editable -->
@ -22,6 +25,9 @@
:key="child.id" :key="child.id"
:element="child" :element="child"
:force-mode="'preview'" :force-mode="'preview'"
:application-context-additions="{
recordIndex: index,
}"
:class="{ :class="{
'repeat-element-preview': index > 0 && isEditMode, 'repeat-element-preview': index > 0 && isEditMode,
}" }"
@ -36,7 +42,7 @@
<AddElementModal <AddElementModal
ref="addElementModal" ref="addElementModal"
:page="page" :page="page"
:element-types-allowed="elementType.childElementTypes" :element-types-allowed="elementType.childElementTypes(page, element)"
></AddElementModal> ></AddElementModal>
</template> </template>
</template> </template>
@ -48,7 +54,7 @@
<AddElementModal <AddElementModal
ref="addElementModal" ref="addElementModal"
:page="page" :page="page"
:element-types-allowed="elementType.childElementTypes" :element-types-allowed="elementType.childElementTypes(page, element)"
></AddElementModal> ></AddElementModal>
</template> </template>
<!-- We have no contents, but we do have children in edit mode --> <!-- We have no contents, but we do have children in edit mode -->

View file

@ -32,10 +32,15 @@ import { BACKGROUND_TYPES, WIDTH_TYPES } from '@baserow/modules/builder/enums'
export default { export default {
name: 'PageElement', name: 'PageElement',
inject: ['builder', 'page', 'mode'], inject: ['builder', 'page', 'mode', 'applicationContext'],
provide() { provide() {
return { return {
mode: this.elementMode, mode: this.elementMode,
applicationContext: {
...this.applicationContext,
element: this.element,
...this.applicationContextAdditions,
},
} }
}, },
props: { props: {
@ -48,6 +53,11 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
applicationContextAdditions: {
type: Object,
required: false,
default: null,
},
}, },
computed: { computed: {
BACKGROUND_TYPES: () => BACKGROUND_TYPES, BACKGROUND_TYPES: () => BACKGROUND_TYPES,

View file

@ -103,6 +103,7 @@ export default {
actionUpdateDataSource: 'dataSource/debouncedUpdate', actionUpdateDataSource: 'dataSource/debouncedUpdate',
actionDeleteDataSource: 'dataSource/delete', actionDeleteDataSource: 'dataSource/delete',
actionFetchDataSources: 'dataSource/fetch', actionFetchDataSources: 'dataSource/fetch',
clearElementContent: 'elementContent/clearElementContent',
}), }),
async shown() { async shown() {
try { try {

View file

@ -15,5 +15,14 @@ import elementSidePanel from '@baserow/modules/builder/mixins/elementSidePanel'
export default { export default {
name: 'GeneralSidePanel', name: 'GeneralSidePanel',
mixins: [elementSidePanel], mixins: [elementSidePanel],
inject: ['applicationContext'],
provide() {
return {
applicationContext: {
...this.applicationContext,
element: this.element,
},
}
},
} }
</script> </script>

View file

@ -213,6 +213,26 @@ export class CurrentRecordDataProviderType extends DataProviderType {
return true return true
} }
getFirstCollectionAncestor(page, element) {
if (!element) {
return null
}
const elementType = this.app.$registry.get('element', element.type)
if (elementType.isCollectionElement) {
return element
}
const ancestors = this.app.store.getters['element/getAncestors'](
page,
element
)
for (const ancestor of ancestors) {
const ancestorType = this.app.$registry.get('element', ancestor.type)
if (ancestorType.isCollectionElement) {
return ancestor
}
}
}
// Loads all element contents // Loads all element contents
async init(applicationContext) { async init(applicationContext) {
const { page } = applicationContext const { page } = applicationContext
@ -252,20 +272,26 @@ export class CurrentRecordDataProviderType extends DataProviderType {
) )
} }
getActionDispatchContext(applicationContext) {
return applicationContext.recordIndex
}
getDataChunk(applicationContext, path) { getDataChunk(applicationContext, path) {
const content = this.getDataContent(applicationContext) const content = this.getDataContent(applicationContext)
return getValueAtPath(content, path.join('.')) return getValueAtPath(content, path.join('.'))
} }
getDataContent(applicationContext) { getDataContent(applicationContext) {
const { element, recordIndex = 0 } = applicationContext const { page, element, recordIndex = 0 } = applicationContext
const collectionElement = this.getFirstCollectionAncestor(page, element)
if (!element) { if (!collectionElement) {
return [] return []
} }
const rows = const rows =
this.app.store.getters['elementContent/getElementContent'](element) this.app.store.getters['elementContent/getElementContent'](
collectionElement
)
const row = { [this.indexKey]: recordIndex, ...rows[recordIndex] } const row = { [this.indexKey]: recordIndex, ...rows[recordIndex] }
@ -284,8 +310,9 @@ export class CurrentRecordDataProviderType extends DataProviderType {
} }
getDataSchema(applicationContext) { getDataSchema(applicationContext) {
const { page, element: { data_source_id: dataSourceId } = {} } = const { page, element } = applicationContext
applicationContext const collectionElement = this.getFirstCollectionAncestor(page, element)
const dataSourceId = collectionElement?.data_source_id
if (!dataSourceId) { if (!dataSourceId) {
return null return null
@ -312,8 +339,9 @@ export class CurrentRecordDataProviderType extends DataProviderType {
getPathTitle(applicationContext, pathParts) { getPathTitle(applicationContext, pathParts) {
if (pathParts.length === 1) { if (pathParts.length === 1) {
const { page, element: { data_source_id: dataSourceId } = {} } = const { page, element } = applicationContext
applicationContext const collectionElement = this.getFirstCollectionAncestor(page, element)
const dataSourceId = collectionElement?.data_source_id
const dataSource = this.app.store.getters[ const dataSource = this.app.store.getters[
'dataSource/getPageDataSourceById' 'dataSource/getPageDataSourceById'

View file

@ -372,22 +372,44 @@ export class ElementType extends Registerable {
const ContainerElementTypeMixin = (Base) => const ContainerElementTypeMixin = (Base) =>
class extends Base { class extends Base {
isContainerElementType = true isContainerElement = true
get elementTypesAll() { get elementTypesAll() {
return Object.values(this.app.$registry.getAll('element')) return Object.values(this.app.$registry.getAll('element'))
} }
/** /**
* Returns an array of element types that are not allowed as children of this element. * Returns an array of element types that are not allowed as children of this element type.
* * @returns {Array} An array of forbidden child element types.
* @returns {Array}
*/ */
get childElementTypesForbidden() { get childElementTypesForbidden() {
return [] return []
} }
get childElementTypes() { /**
* Returns an array of element types that are allowed as children of this element.
* If the parent element we're trying to add a child to has a parent, we'll check
* each parent until the root element if they have any forbidden element types to
* include as well.
* @param page
* @param element
* @returns {Array} An array of permitted child element types.
*/
childElementTypes(page, element) {
if (element.parent_element_id) {
const parentElement = this.app.store.getters['element/getElementById'](
page,
element.parent_element_id
)
const parentElementType = this.app.$registry.get(
'element',
parentElement.type
)
return _.difference(
parentElementType.childElementTypes(page, parentElement),
this.childElementTypesForbidden
)
}
return _.difference(this.elementTypesAll, this.childElementTypesForbidden) return _.difference(this.elementTypesAll, this.childElementTypesForbidden)
} }
@ -475,6 +497,10 @@ export class FormContainerElementType extends ContainerElementTypeMixin(
return FormContainerElementForm return FormContainerElementForm
} }
/**
* Exclude element types which are not a form element.
* @returns {Array} An array of non-form element types.
*/
get childElementTypesForbidden() { get childElementTypesForbidden() {
return this.elementTypesAll.filter((type) => !type.isFormElement) return this.elementTypesAll.filter((type) => !type.isFormElement)
} }
@ -530,9 +556,13 @@ export class ColumnElementType extends ContainerElementTypeMixin(ElementType) {
return ColumnElementForm return ColumnElementForm
} }
/**
* Exclude element types which are containers.
* @returns {Array} An array of container element types.
*/
get childElementTypesForbidden() { get childElementTypesForbidden() {
return this.elementTypesAll.filter( return this.elementTypesAll.filter(
(elementType) => elementType.isContainerElementType (elementType) => elementType.isContainerElement
) )
} }
@ -630,6 +660,7 @@ export class ColumnElementType extends ContainerElementTypeMixin(ElementType) {
const CollectionElementTypeMixin = (Base) => const CollectionElementTypeMixin = (Base) =>
class extends Base { class extends Base {
isCollectionElement = true
getDisplayName(element, { page }) { getDisplayName(element, { page }) {
let suffix = '' let suffix = ''
@ -715,7 +746,7 @@ export class TableElementType extends CollectionElementTypeMixin(ElementType) {
export class RepeatElementType extends ContainerElementTypeMixin( export class RepeatElementType extends ContainerElementTypeMixin(
CollectionElementTypeMixin(ElementType) CollectionElementTypeMixin(ElementType)
) { ) {
getType() { static getType() {
return 'repeat' return 'repeat'
} }
@ -739,6 +770,20 @@ export class RepeatElementType extends ContainerElementTypeMixin(
return RepeatElementForm return RepeatElementForm
} }
/**
* The repeat elements will disallow itself, all form elements, and the
* form container, from being added as children.
* @returns {Array} An array of disallowed child element types.
*/
get childElementTypesForbidden() {
const repeatElement = this.app.$registry.get('element', 'repeat')
const formContainer = this.app.$registry.get('element', 'form_container')
return this.elementTypesAll.filter(
(type) =>
type.isFormElement || type === formContainer || type === repeatElement
)
}
/** /**
* Return an array of placements that are disallowed for the elements to move * Return an array of placements that are disallowed for the elements to move
* in their container. * in their container.

View file

@ -9,12 +9,26 @@ import { resolveColor } from '@baserow/modules/core/utils/colors'
import { themeToColorVariables } from '@baserow/modules/builder/utils/theme' import { themeToColorVariables } from '@baserow/modules/builder/utils/theme'
export default { export default {
inject: ['workspace', 'builder', 'page', 'mode'], inject: ['workspace', 'builder', 'page', 'mode', 'applicationContext'],
provide() {
return {
applicationContext: {
...this.applicationContext,
element: this.element,
...this.applicationContextAdditions,
},
}
},
props: { props: {
element: { element: {
type: Object, type: Object,
required: true, required: true,
}, },
applicationContextAdditions: {
type: Object,
required: false,
default: null,
},
}, },
computed: { computed: {
workflowActionsInProgress() { workflowActionsInProgress() {
@ -31,14 +45,6 @@ export default {
isEditMode() { isEditMode() {
return this.mode === 'editing' return this.mode === 'editing'
}, },
applicationContext() {
return {
builder: this.builder,
page: this.page,
mode: this.mode,
element: this.element,
}
},
runtimeFormulaContext() { runtimeFormulaContext() {
/** /**
* This proxy allow the RuntimeFormulaContextClass to act like a regular object. * This proxy allow the RuntimeFormulaContextClass to act like a regular object.

View file

@ -37,6 +37,7 @@ export default {
page: this.page, page: this.page,
mode, mode,
formulaComponent: ApplicationBuilderFormulaInputGroup, formulaComponent: ApplicationBuilderFormulaInputGroup,
applicationContext: this.applicationContext,
} }
}, },
/** /**

View file

@ -34,6 +34,7 @@ export default {
page: this.page, page: this.page,
mode: this.mode, mode: this.mode,
formulaComponent: ApplicationBuilderFormulaInputGroup, formulaComponent: ApplicationBuilderFormulaInputGroup,
applicationContext: this.applicationContext,
} }
}, },
async asyncData({ store, params, error, $registry, app, req, route }) { async asyncData({ store, params, error, $registry, app, req, route }) {

View file

@ -254,6 +254,20 @@ const actions = {
}) })
throw error throw error
} }
// After deleting the data source, find all collection elements
// which use this data source, and clear their element content.
const dataSourceCollectionElements = page.elements.filter((element) => {
return element.data_source_id === dataSourceToDelete.id
})
dataSourceCollectionElements.map(async (collectionElement) => {
await dispatch(
'elementContent/clearElementContent',
{
element: collectionElement,
},
{ root: true }
)
})
commit('SET_LOADING', { page, value: false }) commit('SET_LOADING', { page, value: false })
}, },
async fetch({ dispatch, commit }, { page }) { async fetch({ dispatch, commit }, { page }) {

View file

@ -60,7 +60,7 @@ const orderElements = (elements, parentElementId = null) => {
const updateCachedValues = (page) => { const updateCachedValues = (page) => {
page.orderedElements = orderElements(page.elements) page.orderedElements = orderElements(page.elements)
page.elementMap = Object.fromEntries( page.elementMap = Object.fromEntries(
page.orderedElements.map((element) => [`${element.id}`, element]) page.elements.map((element) => [`${element.id}`, element])
) )
} }
@ -120,11 +120,7 @@ const actions = {
elementType.afterUpdate(element, page) elementType.afterUpdate(element, page)
}, },
forceDelete({ commit, getters }, { page, elementId }) { forceDelete({ commit, getters }, { page, elementId }) {
const elementsOfPage = getters.getElementsOrdered(page) const elementToDelete = getters.getElementById(page, elementId)
const elementIndex = elementsOfPage.findIndex(
(element) => element.id === elementId
)
const elementToDelete = elementsOfPage[elementIndex]
if (getters.getSelected?.id === elementId) { if (getters.getSelected?.id === elementId) {
commit('SELECT_ITEM', { element: null }) commit('SELECT_ITEM', { element: null })
@ -276,12 +272,7 @@ const actions = {
}) })
}, },
async delete({ dispatch, getters }, { page, elementId }) { async delete({ dispatch, getters }, { page, elementId }) {
const elementsOfPage = getters.getElementsOrdered(page) const elementToDelete = getters.getElementById(page, elementId)
const elementIndex = elementsOfPage.findIndex(
(element) => element.id === elementId
)
const elementToDelete = elementsOfPage[elementIndex]
const descendants = getters.getDescendants(page, elementToDelete) const descendants = getters.getDescendants(page, elementToDelete)
// First delete all children // First delete all children
@ -456,17 +447,25 @@ const getters = {
getParent: (state, getters) => (page, element) => { getParent: (state, getters) => (page, element) => {
return getters.getElementById(page, element?.parent_element_id) return getters.getElementById(page, element?.parent_element_id)
}, },
getAncestors: (state, getters) => (page, element) => { /**
const getElementAncestors = (element) => { * Given an element, return all its ancestors until we reach the root element.
const parentElement = getters.getParent(page, element) * If `parentFirst` is `true` then we reverse the array of elements so that
if (parentElement) { * the element's immediate parent is first, otherwise the root element will be first.
return [...getElementAncestors(parentElement), parentElement] */
} else { getAncestors:
return [] (state, getters) =>
(page, element, parentFirst = true) => {
const getElementAncestors = (element) => {
const parentElement = getters.getParent(page, element)
if (parentElement) {
return [...getElementAncestors(parentElement), parentElement]
} else {
return []
}
} }
} const ancestors = getElementAncestors(element)
return getElementAncestors(element) return parentFirst ? ancestors.reverse() : ancestors
}, },
getSiblings: (state, getters) => (page, element) => { getSiblings: (state, getters) => (page, element) => {
return getters return getters
.getElementsOrdered(page) .getElementsOrdered(page)

View file

@ -1,10 +1,11 @@
import { import {
ElementType,
CheckboxElementType, CheckboxElementType,
DropdownElementType, DropdownElementType,
ElementType,
InputTextElementType, InputTextElementType,
} from '@baserow/modules/builder/elementTypes' } from '@baserow/modules/builder/elementTypes'
import { TestApp } from '@baserow/test/helpers/testApp' import { TestApp } from '@baserow/test/helpers/testApp'
import _ from 'lodash'
describe('elementTypes tests', () => { describe('elementTypes tests', () => {
const testApp = new TestApp() const testApp = new TestApp()
@ -346,4 +347,122 @@ describe('elementTypes tests', () => {
expect(elementType.isValid(element, 'uk')).toBe(true) expect(elementType.isValid(element, 'uk')).toBe(true)
}) })
}) })
describe('elementType childElementTypesForbidden tests', () => {
test('FormContainerElementType forbids non-form elements as children.', () => {
const formContainerElementType = testApp
.getRegistry()
.get('element', 'form_container')
const nonFormElementTypes = Object.values(
testApp.getRegistry().getAll('element')
)
.filter((elementType) => !elementType.isFormElement)
.map((elementType) => elementType.getType())
const forbiddenChildTypes =
formContainerElementType.childElementTypesForbidden.map((el) =>
el.getType()
)
expect(forbiddenChildTypes).toEqual(nonFormElementTypes)
})
test('ColumnElementType forbids container elements as children.', () => {
const columnElementType = testApp.getRegistry().get('element', 'column')
const containerElementTypes = Object.values(
testApp.getRegistry().getAll('element')
)
.filter((elementType) => elementType.isContainerElement)
.map((elementType) => elementType.getType())
const forbiddenChildTypes =
columnElementType.childElementTypesForbidden.map((el) => el.getType())
expect(forbiddenChildTypes).toEqual(containerElementTypes)
})
test('RepeatElementType forbids itself, form elements and the form container as children.', () => {
const repeatElementType = testApp.getRegistry().get('element', 'repeat')
const formContainerElementType = testApp
.getRegistry()
.get('element', 'form_container')
let expectedForbiddenChildTypes = [
repeatElementType.type,
formContainerElementType.type,
]
const formElementTypes = Object.values(
testApp.getRegistry().getAll('element')
)
.filter((elementType) => elementType.isFormElement)
.map((elementType) => elementType.getType())
expectedForbiddenChildTypes =
expectedForbiddenChildTypes.concat(formElementTypes)
const forbiddenChildTypes =
repeatElementType.childElementTypesForbidden.map((el) => el.getType())
expect(forbiddenChildTypes.sort()).toEqual(
expectedForbiddenChildTypes.sort()
)
})
})
describe('elementType childElementTypes tests', () => {
test('childElementTypes called with element with no parent only restricts child types to its own requirements.', () => {
const page = { id: 1, name: 'Contact Us' }
const element = { id: 2, parent_element_id: null }
const formContainerElementType = testApp
.getRegistry()
.get('element', 'form_container')
const formElementTypes = Object.values(
testApp.getRegistry().getAll('element')
)
.filter((elementType) => elementType.isFormElement)
.map((elementType) => elementType.getType())
const childElementTypes = formContainerElementType
.childElementTypes(page, element)
.map((el) => el.getType())
expect(childElementTypes).toEqual(formElementTypes)
})
test('childElementTypes called with element with parent restricts child types using all ancestor childElementTypes requirements.', () => {
const page = { id: 1, name: 'Contact Us' }
const parentElement = {
id: 1,
page_id: page.id,
parent_element_id: null,
type: 'repeat',
}
const element = {
id: 2,
page_id: page.id,
parent_element_id: parentElement.id,
type: 'column',
}
page.elementMap = { 1: parentElement, 2: element }
const allElementTypes = Object.values(
testApp.getRegistry().getAll('element')
).map((el) => el.getType())
const columnElementType = testApp.getRegistry().get('element', 'column')
const forbiddenColumnChildTypes =
columnElementType.childElementTypesForbidden.map((el) => el.getType())
const repeatElementType = testApp.getRegistry().get('element', 'repeat')
const forbiddenRepeatChildTypes =
repeatElementType.childElementTypesForbidden.map((el) => el.getType())
const allExpectedForbiddenChildTypes = forbiddenColumnChildTypes.concat(
forbiddenRepeatChildTypes
)
const expectedAllowedChildTypes = _.difference(
allElementTypes,
allExpectedForbiddenChildTypes
)
const childElementTypes = columnElementType
.childElementTypes(page, element)
.map((el) => el.getType())
expect(childElementTypes).toEqual(expectedAllowedChildTypes)
})
})
}) })