Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Show an error on AI fields with a broken prompt and block generating until fixed",
"issue_origin": "github",
"issue_number": 3088,
"domain": "database",
"bullet_points": [],
"created_at": "2026-07-13"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "feature",
"message": "Add links and rich formatting to form and field descriptions",
"issue_origin": "github",
"issue_number": 1851,
"domain": "database",
"bullet_points": [],
"created_at": "2026-07-12"
}
114 changes: 114 additions & 0 deletions e2e-tests/tests/database/form_view_description_richtext.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { expect, test } from "../baserowTest";
import { createDatabase } from "../../fixtures/database/database";
import { createTable } from "../../fixtures/database/table";
import { createField } from "../../fixtures/database/field";
import {
createFormView,
updateFormFieldOptions,
patchView,
} from "../../fixtures/database/view";
import { FormPage } from "../../pages/database/formPage";
import { User } from "../../fixtures/user";
import { Workspace } from "../../fixtures/workspace";

// Form view descriptions (field-level and form-level) are authored as markdown
// and rendered rich to respondents. These tests lock in the two things that
// matter on the public page: markdown becomes real formatting, and unsafe
// markdown (raw HTML / javascript: links) is rendered inert by the shared
// editor config (Markdown html:false + Link protocol allowlist).
test.describe("Form view rich-text descriptions", () => {
test.describe.configure({ timeout: 60_000 });

let user: User;
let workspace: Workspace;

test.beforeEach(async ({ workspacePage }) => {
user = workspacePage.user;
workspace = workspacePage.workspace;
});

test("renders markdown descriptions as formatting", async ({ page, goto }) => {
const database = await createDatabase(user, "Rich Form DB", workspace);
const table = await createTable(user, "Entries", database, [
["Name"],
["Alice"],
]);
const bio = await createField(user, "Bio", "long_text", {}, table);
const view = await createFormView(user, table);
await patchView(user, view, {
description: "Welcome — read the **instructions** and visit our [site](https://baserow.io).",
});
await updateFormFieldOptions(user, view, {
[bio.id]: {
enabled: true,
required: false,
order: 1,
description:
"Fill in your **bio**. See the [guide](https://baserow.io).\n\n- Be concise\n- Be honest",
},
});

const formPage = new FormPage({ page, goto });
await formPage.gotoPublic(view.slug);

const fieldDesc = page.locator(".form-view__field-description").first();
await fieldDesc.waitFor({ state: "visible" });
await expect(fieldDesc.locator("strong")).toHaveCount(1);
await expect(
fieldDesc.locator('a[href="https://baserow.io"]'),
).toHaveCount(1);
expect(await fieldDesc.locator("ul li").count()).toBeGreaterThanOrEqual(2);

const formDesc = page.locator(".form-view__description").first();
await expect(formDesc.locator("strong")).toHaveCount(1);
await expect(
formDesc.locator('a[href="https://baserow.io"]'),
).toHaveCount(1);
});

test("renders unsafe markdown inert (no script, no javascript: link)", async ({
page,
goto,
}) => {
const database = await createDatabase(user, "XSS Form DB", workspace);
const table = await createTable(user, "Entries", database, [
["Name"],
["Bob"],
]);
const bio = await createField(user, "Bio", "long_text", {}, table);
const view = await createFormView(user, table);
await updateFormFieldOptions(user, view, {
[bio.id]: {
enabled: true,
required: false,
order: 1,
description:
"<script>window.__xss = 1</script> **safe** [bad](javascript:alert(1)) [good](https://baserow.io)",
},
});

let dialogFired = false;
page.on("dialog", async (d) => {
dialogFired = true;
await d.dismiss();
});

const formPage = new FormPage({ page, goto });
await formPage.gotoPublic(view.slug);

const fieldDesc = page.locator(".form-view__field-description").first();
await fieldDesc.waitFor({ state: "visible" });

// The injected script neither executed nor was rendered as an element.
expect(await page.evaluate(() => (window as any).__xss)).toBeUndefined();
expect(dialogFired).toBe(false);
await expect(fieldDesc.locator("script")).toHaveCount(0);
// The javascript: link was dropped, not rendered as a clickable anchor.
await expect(fieldDesc.locator('a[href^="javascript:"]')).toHaveCount(0);
// Safe markdown still renders (bold + the https link survive).
await expect(fieldDesc.locator("strong")).toHaveCount(1);
await expect(
fieldDesc.locator('a[href="https://baserow.io"]'),
).toHaveCount(1);
});
});
7 changes: 7 additions & 0 deletions premium/backend/src/baserow_premium/api/fields/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from rest_framework.status import HTTP_400_BAD_REQUEST

ERROR_AI_FIELD_PROMPT_INVALID = (
"ERROR_AI_FIELD_PROMPT_INVALID",
HTTP_400_BAD_REQUEST,
"The AI field's prompt is broken: {e}",
)
6 changes: 6 additions & 0 deletions premium/backend/src/baserow_premium/api/fields/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@
from baserow.core.handler import CoreHandler
from baserow.core.jobs.handler import JobHandler
from baserow.core.jobs.registries import job_type_registry
from baserow_premium.api.fields.errors import ERROR_AI_FIELD_PROMPT_INVALID
from baserow_premium.fields.actions import GenerateFormulaWithAIActionType
from baserow_premium.fields.exceptions import AIFieldPromptInvalidError
from baserow_premium.fields.job_types import GenerateAIValuesJobType
from baserow_premium.fields.models import AIField
from baserow_premium.license.features import PREMIUM
Expand Down Expand Up @@ -84,6 +86,7 @@ class AsyncGenerateAIFieldValuesView(APIView):
[
"ERROR_GENERATIVE_AI_DOES_NOT_EXIST",
"ERROR_MODEL_DOES_NOT_BELONG_TO_TYPE",
"ERROR_AI_FIELD_PROMPT_INVALID",
]
),
404: get_error_schema(
Expand All @@ -103,6 +106,7 @@ class AsyncGenerateAIFieldValuesView(APIView):
GenerativeAITypeDoesNotExist: ERROR_GENERATIVE_AI_DOES_NOT_EXIST,
ModelDoesNotBelongToType: ERROR_MODEL_DOES_NOT_BELONG_TO_TYPE,
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
AIFieldPromptInvalidError: ERROR_AI_FIELD_PROMPT_INVALID,
}
)
@validate_body(GenerateAIFieldValueViewSerializer, return_validated=True)
Expand All @@ -127,6 +131,8 @@ def post(self, request: Request, field_id: int, data) -> Response:
context=ai_field.table,
)

# An invalid prompt raises `AIFieldPromptInvalidError` from the job type's
# `prepare_values`, mapped to a 400 by `map_exceptions` above.
job = JobHandler().create_and_start_job(
request.user,
GenerateAIValuesJobType.type,
Expand Down
1 change: 1 addition & 0 deletions premium/backend/src/baserow_premium/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class BaserowPremiumConfig(AppConfig):

def ready(self):
# noinspection PyUnresolvedReferences
import baserow_premium.fields.receivers # noqa: F401
import baserow_premium.license.receivers # noqa: F401
import baserow_premium.row_comments.receivers # noqa: F401
from baserow.core.registries import application_type_registry
Expand Down
7 changes: 7 additions & 0 deletions premium/backend/src/baserow_premium/fields/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ class AIFieldEmptyPromptError(Exception):
Raised when the resolved prompt for an AI field is empty, meaning there
is nothing to send to the model.
"""


class AIFieldPromptInvalidError(Exception):
"""
Raised when an AI field's prompt formula is broken (unparseable or references a
field that no longer exists), so values cannot be generated.
"""
80 changes: 72 additions & 8 deletions premium/backend/src/baserow_premium/fields/field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from baserow.contrib.database.fields.models import Field, LinkRowField
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.formula import BaserowFormulaType
from baserow.core.formula.parser.exceptions import BaserowFormulaException
from baserow.core.formula.serializers import FormulaSerializerField
from baserow.core.generative_ai.exceptions import (
GenerativeAITypeDoesNotExist,
Expand Down Expand Up @@ -82,6 +83,7 @@ class AIFieldType(CollationSortMixin, SelectOptionBaseFieldType):
"ai_prompt",
"ai_file_field_id",
"ai_auto_update",
"error",
]
serializer_field_overrides = {
"ai_output_type": serializers.ChoiceField(
Expand Down Expand Up @@ -116,6 +118,12 @@ class AIFieldType(CollationSortMixin, SelectOptionBaseFieldType):
help_text="If set, AI field will be recalculated if a value of a "
"referenced field has been changed.",
),
"error": serializers.CharField(
required=False,
read_only=True,
allow_null=True,
help_text="The error message if the field's prompt is broken, else null.",
),
**SelectOptionBaseFieldType.serializer_field_overrides,
}
api_exceptions_map = {
Expand Down Expand Up @@ -352,14 +360,31 @@ def _validate_field_kwargs(
def get_field_dependencies(
self, field_instance: AIField, field_cache: "FieldCache"
) -> FieldDependencies:
field_ids = set(
extract_field_id_dependencies(field_instance.ai_prompt["formula"])
)
try:
field_ids = set(
extract_field_id_dependencies(field_instance.ai_prompt["formula"])
)
except BaserowFormulaException:
# An unparseable prompt (e.g. from an import) is surfaced via the
# field's `error`; it simply has no field dependencies.
field_ids = set()
if field_instance.ai_file_field_id is not None:
field_ids.add(field_instance.ai_file_field_id)
# Scoped to the field's table, matching `get_ai_prompt_error`; a prompt
# can only reference fields in the same table.
existing_field_ids = set(
Field.objects.filter(id__in=field_ids).values_list("id", flat=True)
Field.objects.filter(
id__in=field_ids, table_id=field_instance.table_id
).values_list("id", flat=True)
)
# A trashed referenced field is declared as a broken reference (by name,
# like formula fields) so the edge survives and restoring the field
# re-links it and re-reports this field's error to the client.
trashed_names = Field.objects_and_trash.filter(
id__in=field_ids - existing_field_ids,
table_id=field_instance.table_id,
trashed=True,
).values_list("name", flat=True)
return [
FieldDependency(
dependency_id=field_id,
Expand All @@ -368,8 +393,46 @@ def get_field_dependencies(
)
for field_id in field_ids
if field_id in existing_field_ids
] + [
FieldDependency(
broken_reference_field_name=name,
dependant=field_instance,
via=None,
)
for name in trashed_names
]

def field_dependency_updated(
self,
field,
updated_field,
updated_old_field,
update_collector,
field_cache,
via_path_to_starting_table=None,
):
# When a referenced field is created, updated, deleted or restored, the
# prompt's validity (and thus the computed `error`) can change. Mark the
# field as changed so it is re-serialized and pushed to the client,
# without touching its stored cell values (a None update statement is
# the collector's "changed without a cell update"). Not
# `add_field_which_has_changed`: that only reports via the
# additional-signals path, which skips the starting table, and the AI
# field usually lives in the same table as the changed dependency.
# (`field_dependency_created` and `field_dependency_deleted` both
# delegate here in the base type.)
update_collector.add_field_with_pending_update_statement(
field, None, via_path_to_starting_table
)
super().field_dependency_updated(
field,
updated_field,
updated_old_field,
update_collector,
field_cache,
via_path_to_starting_table,
)

def _handle_dependent_rows_change(
self,
field: AIField,
Expand Down Expand Up @@ -572,10 +635,11 @@ def after_import_serialized(
field.ai_prompt, id_mapping["database_fields"]
)
save = True
except KeyError:
# Raised when the field ID is not found in the mapping. If that's the
# case, we leave the field ID references broken so that the import
# can still succeed.
except (KeyError, BaserowFormulaException):
# KeyError: a referenced field ID isn't in the mapping.
# BaserowFormulaException: the prompt can't be parsed.
# In both cases we leave the prompt as-is so the import can still
# succeed; the broken state is surfaced via the field's `error`.
pass

if save:
Expand Down
11 changes: 10 additions & 1 deletion premium/backend/src/baserow_premium/fields/job_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@
from baserow.core.jobs.models import JobQuerySet
from baserow.core.jobs.registries import JobType
from baserow.core.utils import ChildProgressBuilder
from baserow_premium.api.fields.errors import ERROR_AI_FIELD_PROMPT_INVALID

from .exceptions import AIFieldEmptyPromptError
from .exceptions import AIFieldEmptyPromptError, AIFieldPromptInvalidError
from .handler import AIFieldHandler
from .models import AIField, AIFieldScheduledUpdate, GenerateAIValuesJob
from .visitors import get_ai_prompt_error


class AIValueUpdate(NamedTuple):
Expand All @@ -57,6 +59,7 @@ class GenerateAIValuesJobType(JobType):
WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST,
AIFieldPromptInvalidError: ERROR_AI_FIELD_PROMPT_INVALID,
}
serializer_field_names = [
"field_id",
Expand Down Expand Up @@ -190,6 +193,12 @@ def prepare_values(self, values, user):

AIFieldHandler.get_valid_model_type_or_raise(ai_field)

# Validate here rather than in the API views so every entry point
# (dedicated endpoint, generic /jobs/ endpoint) shares the invariant.
prompt_error = get_ai_prompt_error(ai_field.ai_prompt, ai_field.table_id)
if prompt_error:
raise AIFieldPromptInvalidError(prompt_error)

if unsaved_job.mode == GenerateAIValuesJob.MODES.AUTO_UPDATE:
if not AIFieldScheduledUpdate.objects.filter(field_id=ai_field.id).exists():
raise ValueError("No rows scheduled for AI field auto update.")
Expand Down
9 changes: 9 additions & 0 deletions premium/backend/src/baserow_premium/fields/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from .ai_field_output_types import TextAIFieldOutputType
from .registries import ai_field_output_registry
from .visitors import get_ai_prompt_error

User = get_user_model()

Expand Down Expand Up @@ -65,6 +66,14 @@ def ai_max_concurrent_generations(self) -> int:

return settings.BASEROW_AI_FIELD_MAX_CONCURRENT_GENERATIONS

@property
def error(self):
# Computed (not stored) prompt error so the field header can show it and
# generation can be blocked. Recomputed whenever the field is serialized.
if not self.table_id:
return None
return get_ai_prompt_error(self.ai_prompt, self.table_id)


class GenerateAIValuesJob(JobWithUserIpAddress, JobWithUndoRedoIds, Job):
class MODES(StrEnum):
Expand Down
11 changes: 11 additions & 0 deletions premium/backend/src/baserow_premium/fields/receivers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.dispatch import receiver

from baserow.contrib.database.table.signals import table_schema_changed
from baserow.core.cache import local_cache


@receiver(table_schema_changed)
def invalidate_ai_prompt_field_ids_cache(sender, table_id, **kwargs):
# Invalidate the cached field ids used by AI prompt validation (see
# visitors.get_table_field_ids) when a field is added/updated/trashed/restored.
local_cache.delete(f"ai_prompt_table_field_ids_{table_id}")
Loading
Loading