1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-07 14:25:37 +00:00

Final fix for workflow action with collection elements

This commit is contained in:
Jérémie Pardou 2025-02-18 16:28:09 +00:00
parent 1172105b93
commit 1a11d5a21c
27 changed files with 203 additions and 143 deletions

View file

@ -396,7 +396,6 @@ class DispatchBuilderWorkflowActionView(APIView):
dispatch_context = BuilderDispatchContext(
request,
workflow_action.page,
element=workflow_action.element,
workflow_action=workflow_action,
)

View file

@ -298,7 +298,9 @@ class CurrentRecordDataProviderType(DataProviderType):
"""
try:
current_record = dispatch_context.request.data["current_record"]
current_record_data = dispatch_context.request.data["current_record"]
current_record = current_record_data["index"]
current_record_id = current_record_data["record_id"]
except KeyError:
return None
@ -318,8 +320,9 @@ class CurrentRecordDataProviderType(DataProviderType):
# Narrow down our range to just our record index.
dispatch_context = dispatch_context.from_context(
dispatch_context,
offset=current_record,
offset=0,
count=1,
only_record_id=current_record_id,
)
return DataSourceDataProviderType().get_data_chunk(

View file

@ -33,6 +33,7 @@ class BuilderDispatchContext(DispatchContext):
"element",
"offset",
"count",
"only_record_id",
"only_expose_public_allowed_properties",
]
@ -44,12 +45,29 @@ class BuilderDispatchContext(DispatchContext):
element: Optional["Element"] = None,
offset: Optional[int] = None,
count: Optional[int] = None,
only_record_id: Optional[int | str] = None,
only_expose_public_allowed_properties: Optional[bool] = True,
):
"""
Dispatch context used in the builder.
:param request: The HTTP request from the view.
:param page: The page related to the dispatch.
:param workflow_action: The workflow action being executed, if any.
:param element: An optional element that triggered the dispatch.
:param offset: When we dispatch a list service, starts by that offset.
:param count: When we dispatch a list service returns that max amount of record.
:param record_id: If we want to narrow down the results of a list service to
only the record with this Id.
:param only_expose_public_allowed_properties: Determines whether only public
allowed properties should be exposed. Defaults to True.
"""
self.request = request
self.page = page
self.workflow_action = workflow_action
self.element = element
self.only_record_id = only_record_id
# Overrides the `request` GET offset/count values.
self.offset = offset

View file

@ -517,9 +517,17 @@ class CollectionElementTypeMixin:
.items()
if any(options.values())
]
if data_source and property_options:
properties.setdefault(data_source.service_id, []).extend(property_options)
# We need the id for the element
if data_source and data_source.service_id:
service = data_source.service.specific
id_property = service.get_type().get_id_property(service)
if id_property not in properties.setdefault(service.id, []):
properties[service.id].append(id_property)
return properties

View file

@ -1082,6 +1082,9 @@ class LocalBaserowListRowsUserServiceType(
# Ensure that only used fields are fetched from the database.
queryset = queryset.only(*available_fields.intersection(only_field_names))
if dispatch_context.only_record_id is not None:
queryset = queryset.filter(id=dispatch_context.only_record_id)
offset, count = dispatch_context.range(service)
# We query one more row to be able to know if there is another page that can be

View file

@ -10,6 +10,13 @@ from baserow.core.services.utils import ServiceAdhocRefinements
class DispatchContext(RuntimeFormulaContext, ABC):
own_properties = []
"""
Should return the record id requested for the given service. Used by list
services to select only one record. For instance by the builder current record
data provider to narrow down the result of a list service.
"""
only_record_id = None
def __init__(self):
self.cache = {} # can be used by data providers to save queries
super().__init__()

View file

@ -68,6 +68,29 @@ class ServiceType(
# `DISPATCH_WORKFLOW_ACTION` should be chosen.
dispatch_type = None
def get_id_property(self, service: Service) -> str:
"""
Returns the property name that contains the unique `ID` of a row for this
service.
:param service: the instance of the service.
:return: a string identifying the ID property name.
"""
# Sane default
return "id"
def get_name_property(self, service: Service) -> Optional[str]:
"""
We need the name of the records for some elements (like the record selector).
This method returns it depending on the service.
:param service: the instance of the service.
:return: a string identifying the name property name.
"""
return None
def prepare_values(
self,
values: Dict[str, Any],
@ -343,29 +366,6 @@ class ListServiceTypeMixin:
returns_list = True
def get_id_property(self, service: Service) -> str:
"""
Returns the property name that contains the unique `ID` of a row for this
service.
:param service: the instance of the service.
:return: a string identifying the ID property name.
"""
# Sane default
return "id"
def get_name_property(self, service: Service) -> Optional[str]:
"""
We need the name of the records for some elements (like the record selector).
This method returns it depending on the service.
:param service: the instance of the service.
:return: a string identifying the name property name.
"""
return None
@abstractmethod
def get_record_names(
self,

View file

@ -1961,6 +1961,7 @@ def test_dispatch_data_sources_list_rows_with_elements(
# Although this Data Source has 2 Fields/Columns, only one is
# returned since only one field_id is used by the Table.
f"field_{field_id}": getattr(row, f"field_{field_id}"),
"id": row.id,
}
)
@ -2043,6 +2044,7 @@ def test_dispatch_data_sources_get_row_with_elements(
assert response.json() == {
str(data_source.id): {
f"field_{field_id}": getattr(rows[db_row_id], f"field_{field_id}"),
"id": rows[db_row_id].id,
}
}
@ -2148,6 +2150,7 @@ def test_dispatch_data_sources_get_and_list_rows_with_elements(
assert response.json() == {
str(data_source_1.id): {
f"field_{fields_1[0].id}": getattr(rows_1[0], f"field_{fields_1[0].id}"),
"id": rows_1[0].id,
},
# Although this Data Source has 2 Fields/Columns, only one is returned
# since only one field_id is used by the Table.
@ -2158,6 +2161,7 @@ def test_dispatch_data_sources_get_and_list_rows_with_elements(
f"field_{fields_2[0].id}": getattr(
rows_2[0], f"field_{fields_2[0].id}"
),
"id": rows_2[0].id,
},
],
},

View file

@ -251,9 +251,7 @@ def test_dispatch_data_sources_list_rows_with_elements(
)
expected_results = [
{
f"field_{field_id}": getattr(row, f"field_{field_id}"),
}
{f"field_{field_id}": getattr(row, f"field_{field_id}"), "id": row.id}
for row in data_source_fixture["rows"]
]
@ -332,6 +330,7 @@ def test_dispatch_data_sources_get_row_with_elements(
assert response.json() == {
str(data_source.id): {
f"field_{field_id}": getattr(rows[db_row_id], f"field_{field_id}"),
"id": rows[db_row_id].id,
}
}
@ -431,6 +430,7 @@ def test_dispatch_data_sources_get_and_list_rows_with_elements(
assert response.json() == {
str(data_source_1.id): {
f"field_{fields_1[0].id}": getattr(rows_1[0], f"field_{fields_1[0].id}"),
"id": rows_1[0].id,
},
# Although this Data Source has 2 Fields/Columns, only one is returned
# since only one field_id is used by the Table.
@ -441,6 +441,7 @@ def test_dispatch_data_sources_get_and_list_rows_with_elements(
f"field_{fields_2[0].id}": getattr(
rows_2[0], f"field_{fields_2[0].id}"
),
"id": rows_2[0].id,
},
],
},
@ -537,7 +538,9 @@ def test_dispatch_data_sources_list_rows_with_elements_and_role(
# Field should only be visible if the user's role allows them
# to see the data source fields.
expected_results.append({field_name: getattr(row, field_name)})
expected_results.append(
{field_name: getattr(row, field_name), "id": row.id}
)
else:
expected_results.append({})

View file

@ -857,10 +857,12 @@ def test_public_dispatch_data_source_view_returns_all_fields(
"has_next_page": False,
"results": [
{
"id": rows[0].id,
f"field_{fields[0].id}": "Paneer Tikka",
f"field_{fields[1].id}": "5",
},
{
"id": rows[1].id,
f"field_{fields[0].id}": "Gobi Manchurian",
f"field_{fields[1].id}": "8",
},
@ -1134,7 +1136,7 @@ def test_public_dispatch_data_sources_list_rows_with_elements_and_role(
expected_results = []
for row in data_source_element_roles_fixture["rows"]:
result = {}
result = {"id": row.id}
if expect_fields:
# Field should only be visible if the user's role allows them
# to see the data source fields.
@ -1318,15 +1320,17 @@ def test_public_dispatch_data_sources_list_rows_with_page_visibility_all(
assert response.status_code == HTTP_200_OK
rows = data_source_element_roles_fixture["rows"]
if expect_fields:
field_name = f"field_{field_id}"
assert response.json() == {
str(data_source.id): {
"has_next_page": False,
"results": [
{field_name: "Apple"},
{field_name: "Banana"},
{field_name: "Cherry"},
{field_name: "Apple", "id": rows[0].id},
{field_name: "Banana", "id": rows[1].id},
{field_name: "Cherry", "id": rows[2].id},
],
},
}
@ -1632,15 +1636,17 @@ def test_public_dispatch_data_sources_list_rows_with_page_visibility_logged_in(
assert response.status_code == HTTP_200_OK
rows = data_source_element_roles_fixture["rows"]
if expect_fields:
field_name = f"field_{field_id}"
assert response.json() == {
str(data_source.id): {
"has_next_page": False,
"results": [
{field_name: "Apple"},
{field_name: "Banana"},
{field_name: "Cherry"},
{field_name: "Apple", "id": rows[0].id},
{field_name: "Banana", "id": rows[1].id},
{field_name: "Cherry", "id": rows[2].id},
],
},
}

View file

@ -1,4 +1,3 @@
import json
from unittest.mock import patch
from django.db import transaction
@ -677,7 +676,7 @@ def test_dispatch_local_baserow_upsert_row_workflow_action_with_current_record(
}
response = api_client.post(
url,
{"current_record": 123},
{"current_record": {"index": 123, "record_id": 123}},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -689,7 +688,7 @@ def test_dispatch_local_baserow_upsert_row_workflow_action_with_current_record(
@pytest.mark.django_db(transaction=True)
def test_dispatch_local_baserow_upsert_row_workflow_action_with_adhoc_refinements(
def test_dispatch_local_baserow_upsert_row_workflow_action_with_unmatching_index_and_record_id(
api_client, data_fixture
):
with transaction.atomic():
@ -705,7 +704,7 @@ def test_dispatch_local_baserow_upsert_row_workflow_action_with_adhoc_refinement
],
)
field = table.field_set.get()
RowHandler().create_rows(
rows = RowHandler().create_rows(
user,
table,
rows_values=[
@ -766,17 +765,6 @@ def test_dispatch_local_baserow_upsert_row_workflow_action_with_adhoc_refinement
kwargs={"workflow_action_id": workflow_action.id},
)
advanced_filters = {
"filter_type": "OR",
"filters": [
{
"field": field.id,
"type": "contains",
"value": "construction",
}
],
}
with patch(
"baserow.contrib.builder.handler.get_builder_used_property_names"
) as used_properties_mock:
@ -786,36 +774,34 @@ def test_dispatch_local_baserow_upsert_row_workflow_action_with_adhoc_refinement
}
model = table.get_model()
# 1. The filters reduce it to 3 results.
# 2. The search query reduces it to 2 results.
# 3. We sort alphabetically, and dispatch the first one,
# "Complex Construction Design".
url_with_querystring = (
f"{url}?filters={json.dumps(advanced_filters)}"
f"&search_query=design&order_by={field.db_column}"
)
# Dispatch at index=0, this will be "Complex Construction Design".
# Dispatch at index=0 but row 3 id, this will be "Complex Construction Design".
response = api_client.post(
url_with_querystring,
{"current_record": 0, "data_source": {"element": table_element.id}},
url,
{
"current_record": {"index": 0, "record_id": rows[2].id},
"data_source": {"element": table_element.id},
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
row3 = model.objects.get(pk=3)
assert getattr(row3, f"field_{field.id}") == "Updated row 3"
row3 = model.objects.get(pk=rows[2].id)
assert getattr(row3, f"field_{field.id}") == f"Updated row {rows[2].id}"
# Dispatch at index=0, this will now be "Simple Construction Design".
# Dispatch at index=0 but row 4 id,
# this will now be "Simple Construction Design".
response = api_client.post(
url_with_querystring,
{"current_record": 0, "data_source": {"element": table_element.id}},
url,
{
"current_record": {"index": 0, "record_id": rows[3].id},
"data_source": {"element": table_element.id},
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
row4 = model.objects.get(pk=4)
assert getattr(row4, f"field_{field.id}") == "Updated row 4"
row4 = model.objects.get(pk=rows[3].id)
assert getattr(row4, f"field_{field.id}") == f"Updated row {rows[3].id}"
@pytest.mark.django_db

View file

@ -1208,21 +1208,15 @@ def test_current_record_provider_get_data_chunk_without_record_index(data_fixtur
def test_current_record_provider_get_data_chunk_for_idx():
current_record_provider = CurrentRecordDataProviderType()
fake_request = HttpRequest()
fake_request.data = {"current_record": 123}
fake_request.data = {"current_record": {"index": 123, "record_id": 123}}
dispatch_context = BuilderDispatchContext(fake_request, None)
assert current_record_provider.get_data_chunk(dispatch_context, ["__idx__"]) == 123
@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 = HttpRequest()
fake_request.user = user
fake_request.data = {"current_record": 0}
table, fields, rows = data_fixture.build_table(
user=user,
columns=[
@ -1252,10 +1246,16 @@ def test_current_record_provider_get_data_chunk(data_fixture):
page=page, element=button_element, event=EventTypes.CLICK, user=user
)
fake_request = HttpRequest()
fake_request.user = user
fake_request.data = {"current_record": {"index": 0, "record_id": rows[0].id}}
dispatch_context = BuilderDispatchContext(
fake_request, page, workflow_action, only_expose_public_allowed_properties=False
)
current_record_provider = CurrentRecordDataProviderType()
assert (
current_record_provider.get_data_chunk(dispatch_context, [field.db_column])
== "Badger"

View file

@ -208,7 +208,7 @@ def test_extract_properties_includes_schema_property_for_nested_collection(
)
properties = RepeatElementType().extract_properties(parent_repeat)
assert properties == {}
assert properties == {data_source.service_id: ["id"]}
# Create a child Repeat with a schema_property
child_repeat = data_fixture.create_builder_repeat_element(
@ -223,8 +223,10 @@ def test_extract_properties_includes_schema_property_for_nested_collection(
properties = RepeatElementType().extract_properties(child_repeat, **formula_context)
# We expect that the schema_property field ID to be present
assert properties == {data_source.service_id: [f"field_{multiple_select_field.id}"]}
# We expect that the schema_property field to be present and the ID
assert properties == {
data_source.service_id: [f"field_{multiple_select_field.id}", "id"]
}
@pytest.mark.django_db
@ -283,4 +285,6 @@ def test_extract_properties_includes_schema_property_for_single_row(
)
properties = RepeatElementType().extract_properties(repeat)
assert properties == {data_source.service_id: [f"field_{multiple_select_field.id}"]}
assert properties == {
data_source.service_id: [f"field_{multiple_select_field.id}", "id"]
}

View file

@ -110,10 +110,10 @@ def test_get_builder_used_property_names_returns_all_property_names(data_fixture
assert list(results) == unordered(["all", "external", "internal"])
assert results["all"][data_source.service_id] == unordered(
[f"field_{field.id}" for field in fields]
[f"field_{field.id}" for field in fields] + ["id"]
)
assert results["external"][data_source.service_id] == unordered(
[f"field_{field.id}" for field in fields]
[f"field_{field.id}" for field in fields] + ["id"]
)
assert results["internal"] == {}
@ -169,10 +169,10 @@ def test_get_builder_used_property_names_returns_some_property_names(data_fixtur
# only one property, ensure that specific property is the only one returned.
assert results == {
"all": {
data_source.service_id: [f"field_{fields[0].id}"],
data_source.service_id: [f"field_{fields[0].id}", "id"],
},
"external": {
data_source.service_id: [f"field_{fields[0].id}"],
data_source.service_id: [f"field_{fields[0].id}", "id"],
},
"internal": {},
}
@ -997,6 +997,7 @@ def test_get_builder_used_property_names_returns_merged_property_names_integrati
"all": {
data_source.service_id: sorted(
[
"id",
f"field_{fields[0].id}",
f"field_{fields[2].id}",
]
@ -1019,6 +1020,7 @@ def test_get_builder_used_property_names_returns_merged_property_names_integrati
"external": {
data_source.service_id: [
f"field_{fields[0].id}", # From heading_element_1
"id",
],
data_source_2.service_id: [
f"field_{fields[2].id}"

View file

@ -10,12 +10,13 @@
<slot name="field-name" :field="field">{{ field.name }}</slot>
</th>
</template>
<template #cell-content="{ rowIndex, value, field }">
<template #cell-content="{ rowIndex, value, field, row }">
<slot
name="cell-content"
:value="value"
:field="field"
:row-index="rowIndex"
:row="row"
>
<td :key="field.id" class="ab-table__cell">
<div class="ab-table__cell-content">

View file

@ -25,6 +25,7 @@
:value="row[field.name]"
:field="field"
:row-index="index"
:row="row"
>
<td :key="field.id" class="baserow-table__cell">
{{ row[field.name] }}
@ -55,6 +56,7 @@
:value="row[field.name]"
:field="field"
:row-index="rowIndex"
:row="row"
>
<td
class="baserow-table__cell"

View file

@ -28,12 +28,13 @@
v-if="index === 0 && isEditMode"
:key="`${child.id}-${index}`"
:element="child"
:application-context-additions="{
recordIndexPath: [
...applicationContext.recordIndexPath,
index,
],
}"
:application-context-additions="
getPerRecordApplicationContextAddition({
applicationContext,
row: content,
rowIndex: index,
})
"
@move="$emit('move', $event)"
/>
<!-- Other iterations are not editable -->
@ -44,12 +45,13 @@
:key="`${child.id}_${index}`"
:element="child"
:force-mode="isEditMode ? 'public' : mode"
:application-context-additions="{
recordIndexPath: [
...applicationContext.recordIndexPath,
index,
],
}"
:application-context-additions="
getPerRecordApplicationContextAddition({
applicationContext,
row: content,
rowIndex: index,
})
"
:class="{
'repeat-element__preview': index > 0 && isEditMode,
}"

View file

@ -13,7 +13,7 @@
:style="getStyleOverride('table')"
:orientation="orientation"
>
<template #cell-content="{ rowIndex, field, value }">
<template #cell-content="{ rowIndex, field, value, row }">
<!--
-- We force-self-alignment to `auto` here to prevent some self-positioning
-- like in buttons or links. we want to position the content through the table
@ -34,14 +34,14 @@
:is="collectionFieldTypes[field.type].component"
:element="element"
:field="field"
:application-context-additions="{
recordIndexPath: [
...applicationContext.recordIndexPath,
:application-context-additions="
getPerRecordApplicationContextAddition({
applicationContext,
row,
rowIndex,
],
field,
dispatchRefinements: adhocRefinements,
}"
field,
})
"
v-bind="value"
/>
</div>
@ -117,6 +117,7 @@ export default {
})
)
newRow.__id__ = uuid()
newRow.__recordId__ = row.__recordId__
return newRow
})
},

View file

@ -384,7 +384,10 @@ export class CurrentRecordDataProviderType extends DataProviderType {
}
getActionDispatchContext(applicationContext) {
return applicationContext.recordIndexPath.at(-1)
return {
record_id: applicationContext.recordId,
index: applicationContext.recordIndexPath.at(-1),
}
}
getDataChunk(applicationContext, path) {

View file

@ -64,19 +64,6 @@ export class Event {
)
}
// If we're firing a workflow action, and the collection element it's associated
// with is currently being filtered, we must forward this on to the workflow
// action dispatch so that the backend fires at the correct current_record.
if (
Object.prototype.hasOwnProperty.call(
applicationContext,
'dispatchRefinements'
)
) {
workflowActionContext.dispatchRefinements =
applicationContext.dispatchRefinements
}
const localResolveFormula = (formula) => {
const formulaFunctions = {
get: (name) => {

View file

@ -176,5 +176,22 @@ export default {
this.contentFetchEnabled = false
}
},
getPerRecordApplicationContextAddition({
applicationContext,
row,
rowIndex,
field = null,
}) {
const newApplicationContext = {
recordIndexPath: [...applicationContext.recordIndexPath, rowIndex],
}
if (field) {
newApplicationContext.field = field
}
if (this.element.data_source_id) {
newApplicationContext.recordId = row.__recordId__
}
return newApplicationContext
},
},
}

View file

@ -1,5 +1,3 @@
import { prepareDispatchParams } from '@baserow/modules/builder/utils/params'
export default (client) => {
return {
create(pageId, workflowActionType, eventType, configuration = null) {
@ -35,12 +33,10 @@ export default (client) => {
payload
)
},
dispatch(workflowActionId, data, dispatchRefinements) {
const params = prepareDispatchParams(dispatchRefinements)
dispatch(workflowActionId, data) {
return client.post(
`builder/workflow_action/${workflowActionId}/dispatch/`,
data,
{ params }
data
)
},
}

View file

@ -246,7 +246,10 @@ const actions = {
// using the results key and set the range for future paging.
commit('SET_CONTENT', {
element,
value: data.results,
value: data.results.map((row) => ({
...row,
__recordId__: row[serviceType.getIdProperty(service, row)],
})),
range,
})
} else {

View file

@ -200,15 +200,10 @@ const actions = {
updateContext.promiseResolve = resolve
})
},
async dispatchAction(
{ dispatch },
{ workflowActionId, workflowActionContext, data }
) {
const { dispatchRefinements = {} } = workflowActionContext
async dispatchAction({ dispatch }, { workflowActionId, data }) {
const { data: result } = await WorkflowActionService(this.$client).dispatch(
workflowActionId,
data,
dispatchRefinements
data
)
return result
},

View file

@ -180,10 +180,8 @@ export class RefreshDataSourceWorkflowActionType extends WorkflowActionType {
export class WorkflowActionServiceType extends WorkflowActionType {
execute({ workflowAction: { id }, applicationContext, resolveFormula }) {
const { workflowActionContext } = applicationContext
return this.app.store.dispatch('workflowAction/dispatchAction', {
workflowActionId: id,
workflowActionContext,
data: DataProviderType.getAllActionDispatchContext(
this.app.$registry.getAll('builderDataProvider'),
applicationContext

View file

@ -35,6 +35,22 @@ export class ServiceType extends Registerable {
return false
}
/**
* In a service which returns a list, this method is used to
* return the name of the given record.
*/
getRecordName(service, record) {
throw new Error('Must be set on the type.')
}
/**
* In a service which returns a list, this method is used to
* return the id of the given record.
*/
getIdProperty(service, record) {
throw new Error('Must be set on the type.')
}
/**
* The maximum number of records that can be returned by this service
*/

View file

@ -38,12 +38,8 @@ export class LocalBaserowTableServiceType extends ServiceType {
return service.context_data_schema
}
/**
* In a Local Baserow service which returns a list, this method is used to
* return the name of the given record.
*/
getRecordName(service, record) {
return ''
getIdProperty(service, record) {
return 'id'
}
/**