1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-03 04:35:31 +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
)
dispatch_context = BuilderDispatchContext(request, workflow_action.page)
dispatch_context = BuilderDispatchContext(
request, workflow_action.page, workflow_action=workflow_action
)
response = BuilderWorkflowActionService().dispatch_action(
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.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.workflow_actions.handler import (
BuilderWorkflowActionHandler,
@ -185,9 +188,35 @@ class CurrentRecordDataProviderType(DataProviderType):
type = "current_record"
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):
"""

View file

@ -1,3 +1,5 @@
from typing import TYPE_CHECKING, Optional
from rest_framework.request import Request
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.core.services.dispatch_context import DispatchContext
if TYPE_CHECKING:
from baserow.core.workflow_actions.models import WorkflowAction
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.page = page
self.workflow_action = workflow_action
# Overrides the `request` GET offset/count values.
self.offset = offset
self.count = count
super().__init__()
@ -19,17 +38,24 @@ class BuilderDispatchContext(DispatchContext):
return builder_data_provider_type_registry
def range(self, service):
"""Return page range from the GET parameters."""
"""
Return page range from the `offset`, `count` kwargs,
or the GET parameters.
"""
try:
offset = int(self.request.GET.get("offset", 0))
except ValueError:
offset = 0
if self.offset is not None and self.count is not None:
offset = self.offset
count = self.count
else:
try:
offset = int(self.request.GET.get("offset", 0))
except ValueError:
offset = 0
try:
count = int(self.request.GET.get("count", 20))
except ValueError:
count = 20
try:
count = int(self.request.GET.get("count", 20))
except ValueError:
count = 20
# max prevent negative values
return [

View file

@ -1,5 +1,5 @@
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 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.registries import (
ElementType,
ElementTypeSubClass,
element_type_registry,
)
from baserow.contrib.builder.elements.types import (
ElementForUpdate,
ElementsAndWorkflowActions,
)
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.exceptions import IdDoesNotExist
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:
allowed_fields_create = [
@ -94,6 +99,47 @@ class ElementHandler:
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(
self, element_id: int, base_queryset: Optional[QuerySet] = None
) -> ElementForUpdate:
@ -120,6 +166,7 @@ class ElementHandler:
page: Page,
base_queryset: Optional[QuerySet] = None,
specific: bool = True,
use_cache: bool = True,
) -> Union[QuerySet[Element], Iterable[Element]]:
"""
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 specific: Whether to return the generic elements or the specific
instances.
:param use_cache: Whether to use the cached elements on the page or not.
: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 = queryset.filter(page=page)
if specific:
queryset = queryset.select_related("content_type")
return specific_iterator(queryset)
elements = specific_iterator(queryset)
else:
return queryset
elements = queryset
if use_cache:
setattr(page, cache_key, list(elements))
return elements
def create_element(
self,

View file

@ -2,9 +2,12 @@ from abc import ABC, abstractmethod
from baserow.core.formula.runtime_formula_context import RuntimeFormulaContext
from baserow.core.services.models import Service
from baserow.core.services.types import RuntimeFormulaContextSubClass
class DispatchContext(RuntimeFormulaContext, ABC):
own_properties = []
def __init__(self):
self.cache = {} # can be used by data providers to save queries
super().__init__()
@ -16,3 +19,24 @@ class DispatchContext(RuntimeFormulaContext, ABC):
: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 .models import Service
from baserow.core.formula.runtime_formula_context import RuntimeFormulaContext
from baserow.core.services.models import Service
class ServiceDict(TypedDict):
@ -34,3 +35,7 @@ ServiceSortDictSubClass = TypeVar("ServiceSortDictSubClass", bound="ServiceSortD
ServiceSubClass = TypeVar("ServiceSubClass", bound="Service")
ServiceForUpdate = NewType("ServiceForUpdate", Service)
RuntimeFormulaContextSubClass = TypeVar(
"RuntimeFormulaContextSubClass", bound=RuntimeFormulaContext
)

View file

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

View file

@ -10,6 +10,7 @@ from baserow.contrib.builder.elements.models import (
ImageElement,
InputTextElement,
LinkElement,
RepeatElement,
TableElement,
TextElement,
)
@ -96,6 +97,14 @@ class ElementFixtures:
element = self.create_builder_element(DropdownElement, user, page, **kwargs)
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):
if user is None:
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.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.exceptions import ServiceImproperlyConfigured
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
@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
def test_current_record_provider_type_import_path(data_fixture):
# 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 django.http import HttpRequest
import pytest
from rest_framework.request import Request
from baserow.contrib.builder.data_sources.builder_dispatch_context import (
BuilderDispatchContext,
)
@ -24,3 +29,24 @@ def test_dispatch_context_page_range():
dispatch_context = BuilderDispatchContext(request, None)
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
def test_get_elements(data_fixture):
def test_get_elements(data_fixture, django_assert_num_queries):
page = data_fixture.create_builder_page()
element1 = 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)
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] == [
element1.id,
@ -69,8 +73,31 @@ def test_get_elements(data_fixture):
]
assert isinstance(elements[0], HeadingElement)
assert isinstance(elements[1], HeadingElement)
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
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_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>
import FormulaInputGroup from '@baserow/modules/core/components/formula/FormulaInputGroup'
import { DataSourceDataProviderType } from '@baserow/modules/builder/dataProviderTypes'
export default {
name: 'ApplicationBuilderFormulaInputGroup',
components: { FormulaInputGroup },

View file

@ -34,7 +34,12 @@
@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
v-show="isSelected"
@ -95,6 +100,11 @@ export default {
required: false,
default: false,
},
applicationContextAdditions: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
@ -144,7 +154,10 @@ export default {
return elementType.getPlacementsDisabled(this.page, this.element)
},
elementTypesAllowed() {
return this.parentElementType?.childElementTypes || null
return (
this.parentElementType?.childElementTypes(this.page, this.element) ||
null
)
},
canCreate() {
return this.$hasPermission(

View file

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

View file

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

View file

@ -13,6 +13,9 @@
v-if="index === 0 && isEditMode"
:key="child.id"
:element="child"
:application-context-additions="{
recordIndex: index,
}"
@move="moveElement(child, $event)"
/>
<!-- Other iterations are not editable -->
@ -22,6 +25,9 @@
:key="child.id"
:element="child"
:force-mode="'preview'"
:application-context-additions="{
recordIndex: index,
}"
:class="{
'repeat-element-preview': index > 0 && isEditMode,
}"
@ -36,7 +42,7 @@
<AddElementModal
ref="addElementModal"
:page="page"
:element-types-allowed="elementType.childElementTypes"
:element-types-allowed="elementType.childElementTypes(page, element)"
></AddElementModal>
</template>
</template>
@ -48,7 +54,7 @@
<AddElementModal
ref="addElementModal"
:page="page"
:element-types-allowed="elementType.childElementTypes"
:element-types-allowed="elementType.childElementTypes(page, element)"
></AddElementModal>
</template>
<!-- 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 {
name: 'PageElement',
inject: ['builder', 'page', 'mode'],
inject: ['builder', 'page', 'mode', 'applicationContext'],
provide() {
return {
mode: this.elementMode,
applicationContext: {
...this.applicationContext,
element: this.element,
...this.applicationContextAdditions,
},
}
},
props: {
@ -48,6 +53,11 @@ export default {
required: false,
default: null,
},
applicationContextAdditions: {
type: Object,
required: false,
default: null,
},
},
computed: {
BACKGROUND_TYPES: () => BACKGROUND_TYPES,

View file

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

View file

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

View file

@ -213,6 +213,26 @@ export class CurrentRecordDataProviderType extends DataProviderType {
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
async init(applicationContext) {
const { page } = applicationContext
@ -252,20 +272,26 @@ export class CurrentRecordDataProviderType extends DataProviderType {
)
}
getActionDispatchContext(applicationContext) {
return applicationContext.recordIndex
}
getDataChunk(applicationContext, path) {
const content = this.getDataContent(applicationContext)
return getValueAtPath(content, path.join('.'))
}
getDataContent(applicationContext) {
const { element, recordIndex = 0 } = applicationContext
if (!element) {
const { page, element, recordIndex = 0 } = applicationContext
const collectionElement = this.getFirstCollectionAncestor(page, element)
if (!collectionElement) {
return []
}
const rows =
this.app.store.getters['elementContent/getElementContent'](element)
this.app.store.getters['elementContent/getElementContent'](
collectionElement
)
const row = { [this.indexKey]: recordIndex, ...rows[recordIndex] }
@ -284,8 +310,9 @@ export class CurrentRecordDataProviderType extends DataProviderType {
}
getDataSchema(applicationContext) {
const { page, element: { data_source_id: dataSourceId } = {} } =
applicationContext
const { page, element } = applicationContext
const collectionElement = this.getFirstCollectionAncestor(page, element)
const dataSourceId = collectionElement?.data_source_id
if (!dataSourceId) {
return null
@ -312,8 +339,9 @@ export class CurrentRecordDataProviderType extends DataProviderType {
getPathTitle(applicationContext, pathParts) {
if (pathParts.length === 1) {
const { page, element: { data_source_id: dataSourceId } = {} } =
applicationContext
const { page, element } = applicationContext
const collectionElement = this.getFirstCollectionAncestor(page, element)
const dataSourceId = collectionElement?.data_source_id
const dataSource = this.app.store.getters[
'dataSource/getPageDataSourceById'

View file

@ -372,22 +372,44 @@ export class ElementType extends Registerable {
const ContainerElementTypeMixin = (Base) =>
class extends Base {
isContainerElementType = true
isContainerElement = true
get elementTypesAll() {
return Object.values(this.app.$registry.getAll('element'))
}
/**
* Returns an array of element types that are not allowed as children of this element.
*
* @returns {Array}
* 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.
*/
get childElementTypesForbidden() {
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)
}
@ -475,6 +497,10 @@ export class FormContainerElementType extends ContainerElementTypeMixin(
return FormContainerElementForm
}
/**
* Exclude element types which are not a form element.
* @returns {Array} An array of non-form element types.
*/
get childElementTypesForbidden() {
return this.elementTypesAll.filter((type) => !type.isFormElement)
}
@ -530,9 +556,13 @@ export class ColumnElementType extends ContainerElementTypeMixin(ElementType) {
return ColumnElementForm
}
/**
* Exclude element types which are containers.
* @returns {Array} An array of container element types.
*/
get childElementTypesForbidden() {
return this.elementTypesAll.filter(
(elementType) => elementType.isContainerElementType
(elementType) => elementType.isContainerElement
)
}
@ -630,6 +660,7 @@ export class ColumnElementType extends ContainerElementTypeMixin(ElementType) {
const CollectionElementTypeMixin = (Base) =>
class extends Base {
isCollectionElement = true
getDisplayName(element, { page }) {
let suffix = ''
@ -715,7 +746,7 @@ export class TableElementType extends CollectionElementTypeMixin(ElementType) {
export class RepeatElementType extends ContainerElementTypeMixin(
CollectionElementTypeMixin(ElementType)
) {
getType() {
static getType() {
return 'repeat'
}
@ -739,6 +770,20 @@ export class RepeatElementType extends ContainerElementTypeMixin(
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
* 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'
export default {
inject: ['workspace', 'builder', 'page', 'mode'],
inject: ['workspace', 'builder', 'page', 'mode', 'applicationContext'],
provide() {
return {
applicationContext: {
...this.applicationContext,
element: this.element,
...this.applicationContextAdditions,
},
}
},
props: {
element: {
type: Object,
required: true,
},
applicationContextAdditions: {
type: Object,
required: false,
default: null,
},
},
computed: {
workflowActionsInProgress() {
@ -31,14 +45,6 @@ export default {
isEditMode() {
return this.mode === 'editing'
},
applicationContext() {
return {
builder: this.builder,
page: this.page,
mode: this.mode,
element: this.element,
}
},
runtimeFormulaContext() {
/**
* This proxy allow the RuntimeFormulaContextClass to act like a regular object.

View file

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

View file

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

View file

@ -254,6 +254,20 @@ const actions = {
})
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 })
},
async fetch({ dispatch, commit }, { page }) {

View file

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

View file

@ -1,10 +1,11 @@
import {
ElementType,
CheckboxElementType,
DropdownElementType,
ElementType,
InputTextElementType,
} from '@baserow/modules/builder/elementTypes'
import { TestApp } from '@baserow/test/helpers/testApp'
import _ from 'lodash'
describe('elementTypes tests', () => {
const testApp = new TestApp()
@ -346,4 +347,122 @@ describe('elementTypes tests', () => {
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)
})
})
})