From 29ae0578cca952c016b1582eda0840340e76958e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:34:12 +0200 Subject: [PATCH 1/4] chore: add claude configuration from generic files (#5090) --- .agents/skills/add-django-config-env-var/SKILL.md | 8 +++++--- .agents/skills/create-update-service/SKILL.md | 5 +++-- .agents/skills/write-frontend-unit-test/SKILL.md | 3 ++- .claude/skills | 1 + AGENTS.md | 10 ++++++++++ CLAUDE.md | 5 +++++ 6 files changed, 26 insertions(+), 6 deletions(-) create mode 120000 .claude/skills create mode 100644 CLAUDE.md diff --git a/.agents/skills/add-django-config-env-var/SKILL.md b/.agents/skills/add-django-config-env-var/SKILL.md index e3277edd08..b283c82d05 100644 --- a/.agents/skills/add-django-config-env-var/SKILL.md +++ b/.agents/skills/add-django-config-env-var/SKILL.md @@ -1,6 +1,7 @@ --- -name: add-django-config-env-var +name: Add Django Config Env Var description: Add a new environment variable for a Django setting in Baserow and propagate it to the few repo files that usually need it. Use this when a request says a config env var must be added in several places or references `INTEGRATION_LOCAL_BASEROW_PAGE_SIZE_LIMIT` as the pattern to follow. +version: 1.0.0 --- # Add Django Config Env Var @@ -17,6 +18,7 @@ When adding a new setting, usually check these files: - `docker-compose.yml` - `docker-compose.no-caddy.yml` - `web-frontend/env-remap.mjs` +- `docs/installation/configuration.md` — the canonical env-var reference table; add a row in the right section - Backend or frontend code that uses the setting - A focused test if behavior changes @@ -44,7 +46,7 @@ MY_SETTING = int(os.getenv("BASEROW_MY_SETTING", 123)) 5. Add or update a targeted test if the setting changes behavior. -6. Add the related documentation +6. Add the related documentation in `docs/installation/configuration.md` — find the right section (e.g. Backend Configuration, Integration Configuration) and add a table row matching the format of the nearest existing entry. ## Quick Checklist @@ -53,7 +55,7 @@ MY_SETTING = int(os.getenv("BASEROW_MY_SETTING", 123)) 3. Add the Nuxt remap if frontend code needs it 4. Use `settings.` in code 5. Add a focused test if needed -6. Add the documentation +6. Add a row to `docs/installation/configuration.md` ## Guardrails diff --git a/.agents/skills/create-update-service/SKILL.md b/.agents/skills/create-update-service/SKILL.md index 18e8de5e42..3ce7f38fcd 100644 --- a/.agents/skills/create-update-service/SKILL.md +++ b/.agents/skills/create-update-service/SKILL.md @@ -1,6 +1,7 @@ --- -name: create-update-service -description: Allow to create or update Baserow Integrations and Services +name: Integrations and Services +description: Create or update Baserow integration types and service types in `contrib/integrations`. Use when adding a new ServiceType/IntegrationType subclass, registering one in `apps.py` or `plugin.js`, or updating an existing dispatch/auth flow. +version: 1.0.0 --- # Create Or Update Baserow Services And Integrations diff --git a/.agents/skills/write-frontend-unit-test/SKILL.md b/.agents/skills/write-frontend-unit-test/SKILL.md index 85981e2f77..d5b2adb32f 100644 --- a/.agents/skills/write-frontend-unit-test/SKILL.md +++ b/.agents/skills/write-frontend-unit-test/SKILL.md @@ -1,6 +1,7 @@ --- -name: write-frontend-unit-test +name: Write Frontend Unit Test description: Write or update Baserow frontend unit tests for core, premium, or enterprise code using the repo's existing Vitest, Nuxt, Vue Test Utils, TestApp, and snapshot patterns. +version: 1.0.0 --- # Write Baserow Frontend Unit Tests diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000000..2b7a412b8f --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 0dbeaf9a2c..6b911cf6d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,16 @@ Examples: `just b test backend/tests/path/`, `just b test-coverage`, `just f tes Recent history favors short, imperative subjects, often with Conventional Commit prefixes such as `fix:`, `feat:`, and `chore(deps):`. Branch from `develop`, keep PRs focused, and link the related issue or discussion. Include a clear summary, note schema or env changes, attach screenshots for UI work, add a changelog entry when required, and make sure the relevant lint and test commands pass before opening the PR. +## Project Skills + +Reusable skills live in `.agents/skills/`. Each subdirectory is a self-contained skill with a `SKILL.md` that describes when and how to apply it. Use these instead of re-deriving the same workflow from scratch. + +| Skill directory | When to use | +|---|---| +| `add-django-config-env-var` | Adding a new Django setting backed by an env var and propagating it to `base.py`, docker-compose files, `env-remap.mjs`, and `docs/installation/configuration.md` | +| `write-frontend-unit-test` | Writing or fixing frontend unit tests in `web-frontend`, `premium/web-frontend`, or `enterprise/web-frontend` | +| `create-update-service` | Creating or updating an integration type or service type in `contrib/integrations` | + ## Security & Configuration Tips Do not commit secrets or local overrides. Use `.env.local` for development, keep production settings in the documented deploy configs, and report vulnerabilities privately via the contact path in `CONTRIBUTING.md` rather than opening a public issue. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..f6a54e0628 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +@AGENTS.md + +## Skills + +`.claude/skills` is a symlink to `.agents/skills`, the canonical location for project skills. Both paths resolve to the same directory. From 7bce809cbc6dff9e9df4deece43b6321b92879f7 Mon Sep 17 00:00:00 2001 From: Davide Silvestri <75379892+silvestrid@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:55:53 +0200 Subject: [PATCH 2/4] feat(assistant) 2/3: add application builder tools for pages, elements, data sources, and actions (#4954) * feat(assistant): add application builder tools module Add 19 tool functions for creating/managing pages, elements, data sources, and workflow actions in Baserow builder apps. Includes permission-checked CRUD helpers, formula generation context, element type models with dispatch tables, and routing rules for all builder operations. * feat(assistant): add theme catalog, apply_theme, and theme templates Add THEME_CATALOG with 20 theme definitions, ThemeName type, apply_theme() helper, and auto-theme application on builder creation. Includes JSON and ZIP template files for all themes. * feat(assistant): register BuilderToolType and wire up imports Register BuilderToolType in apps.py, consolidate ToolInputError in builder.helpers, remove try/except guard on builder import in toolset, and minor formatting fixes. * feat(assistant): add builder page navigation and UI context Add builder-page navigation handler in AssistantPanel, page name display in AssistantUiContext, builder page context extraction in store, and undo/redo scope dispatch for builder pages. * test(assistant): add builder tools and eval tests Add 55 unit tests for builder tools (pages, elements, data sources, workflow actions, styles, themes), 4 element move tests, restore 3 theme tests in core tools, and 11 LLM eval tests for builder scenarios. * lint * refactor(assistant): extract theme code for separate PR Remove theme catalog, apply_theme, set_theme, and 20 new template files to be re-added on pydantic-ai-pr3 as a focused theme PR. Co-Authored-By: Claude Opus 4.6 * fix(assistant): add missing application_type_registry import in core types * fix(assistant): add prerequisite hint to create_display_elements and update builder evals * feat(assistant): add view_id to data source create and update Allow data sources to reference a database table view for filtering. Instead of reimplementing the full filter model, this leverages existing create_views + create_view_filters tools to set up filters on a view, then links it via view_id. * feat(assistant): add user source setup, role visibility, and login page - Add setup_user_source tool: creates users table with fields and example rows, configures Local Baserow user source with password auth, creates login page with auth_form element, and sets builder.login_page - Add update_builder core tool for application-level settings (login_page_id) and register it in all mode-aware toolsets - Add role_type and roles fields to PageCreate, PageUpdate, PageItem, ElementItemCreate, and ElementUpdate for role-based visibility - Add auth_form element type with user_source_id and login_button_label - Enrich list_pages response with user_sources, available_roles, and login_page_id so the LLM knows what auth is configured - Add 3 unit tests and 2 eval scenarios for user source setup * fix(assistant): address copilot feedback - Guard `update_single_element_formulas` against KeyError from `get_first_ancestor_of_type` (shared page elements), matching the existing guard in `update_element_formulas` - Enforce XOR validation on UserSourceSetup for table_id/database_id - Use `literal_or_placeholder` in notification/open_page workflow action kwargs so plain strings are properly quoted as formula literals - Raise ToolInputError when table lookup via .first() returns None in data source and workflow action types - Reorder page.js selectById to validate pageId before setting undo/redo scope Co-Authored-By: Claude Opus 4.6 (1M context) * fix(assistant): fix view type bugs in ViewItemCreate and from_django_orm - Default `public` to False instead of requiring it - Read grid `row_height` from ORM instead of hardcoding "small" - Use `is None` check for required fields to accept valid falsy values * fix(assistant): prevent shared header misuse and fix collection element formulas Strengthen guidance so the AI doesn't place page-specific content in shared headers/footers and correctly uses current_record instead of data_source..0 for formulas inside table/repeat elements. * fix: add pytest.skip to flaky test test_async_start_workflow_rate_limited_runs_eventually_disable_workflow * fix: address feedback * Revert "fix: add pytest.skip to flaky test test_async_start_workflow_rate_limited_runs_eventually_disable_workflow" This reverts commit 39ca93eebf0dcbf308082df0ad16399ad59ab873. --------- Co-authored-by: Claude Opus 4.6 --- .../contrib/builder/data_sources/service.py | 11 +- .../backend/src/baserow_enterprise/apps.py | 4 + .../baserow_enterprise/assistant/prompts.py | 18 +- .../assistant/tools/automation/tools.py | 1 - .../assistant/tools/builder/__init__.py | 1 + .../assistant/tools/builder/agents.py | 675 +++++ .../assistant/tools/builder/helpers.py | 987 +++++++ .../assistant/tools/builder/prompts.py | 51 + .../assistant/tools/builder/tool_types.py | 20 + .../assistant/tools/builder/tools.py | 1530 ++++++++++ .../assistant/tools/builder/types/__init__.py | 73 + .../tools/builder/types/data_source.py | 375 +++ .../assistant/tools/builder/types/element.py | 2269 +++++++++++++++ .../assistant/tools/builder/types/page.py | 153 + .../tools/builder/types/user_source.py | 52 + .../tools/builder/types/workflow_action.py | 504 ++++ .../assistant/tools/core/tools.py | 42 +- .../assistant/tools/core/types.py | 34 +- .../assistant/tools/database/helpers.py | 6 +- .../assistant/tools/database/tools.py | 4 +- .../assistant/tools/database/types/views.py | 6 +- .../assistant/tools/toolset.py | 17 +- .../assistant/evals/test_eval_builder.py | 1198 ++++++++ .../evals/test_eval_builder_proactive.py | 302 ++ .../evals/test_eval_builder_user_source.py | 211 ++ ...st_assistant_builder_element_move_tools.py | 221 ++ .../assistant/test_assistant_builder_tools.py | 2463 +++++++++++++++++ .../components/assistant/AssistantPanel.vue | 18 + .../assistant/AssistantUiContext.vue | 2 + .../baserow_enterprise/store/assistant.js | 10 + web-frontend/modules/builder/store/page.js | 16 +- .../builder/utils/undoRedoConstants.js | 8 + 32 files changed, 11246 insertions(+), 36 deletions(-) create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/__init__.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/agents.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/helpers.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/prompts.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/tool_types.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/tools.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/__init__.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/data_source.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/element.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/page.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/user_source.py create mode 100644 enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/workflow_action.py create mode 100644 enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder.py create mode 100644 enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder_proactive.py create mode 100644 enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder_user_source.py create mode 100644 enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_builder_element_move_tools.py create mode 100644 enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_builder_tools.py create mode 100644 web-frontend/modules/builder/utils/undoRedoConstants.js diff --git a/backend/src/baserow/contrib/builder/data_sources/service.py b/backend/src/baserow/contrib/builder/data_sources/service.py index ece9045612..182f7dbdda 100644 --- a/backend/src/baserow/contrib/builder/data_sources/service.py +++ b/backend/src/baserow/contrib/builder/data_sources/service.py @@ -63,12 +63,15 @@ def get_data_source(self, user: AbstractUser, data_source_id: int) -> DataSource return data_source - def get_data_sources(self, user: AbstractUser, page: Page) -> List[DataSource]: + def get_data_sources( + self, user: AbstractUser, page: Page, with_shared: bool = False + ) -> List[DataSource]: """ Gets all the data_sources of a given page visible to the given user. :param user: The user trying to get the data_sources. :param page: The page that holds the data_sources. + :param with_shared: Whether shared data sources should be included in the result. :return: The data_sources of that page. """ @@ -86,7 +89,11 @@ def get_data_sources(self, user: AbstractUser, page: Page) -> List[DataSource]: workspace=page.builder.workspace, ) - return self.handler.get_data_sources(page, base_queryset=user_data_sources) + return self.handler.get_data_sources( + page, + base_queryset=user_data_sources, + with_shared=with_shared, + ) def get_builder_data_sources( self, user: AbstractUser, builder: "Builder", with_cache=False diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py index 1c61528d94..fde8d97625 100755 --- a/enterprise/backend/src/baserow_enterprise/apps.py +++ b/enterprise/backend/src/baserow_enterprise/apps.py @@ -360,6 +360,9 @@ def ready(self): from baserow_enterprise.assistant.tools.automation.tool_types import ( AutomationToolType, ) + from baserow_enterprise.assistant.tools.builder.tool_types import ( + BuilderToolType, + ) from baserow_enterprise.assistant.tools.core.tool_types import CoreToolType from baserow_enterprise.assistant.tools.database.tool_types import ( DatabaseToolType, @@ -378,6 +381,7 @@ def ready(self): assistant_tool_registry.register(CoreToolType()) assistant_tool_registry.register(DatabaseToolType()) assistant_tool_registry.register(AutomationToolType()) + assistant_tool_registry.register(BuilderToolType()) assistant_tool_registry.register(SearchDocsToolType()) # The signals must always be imported last because they use the registries diff --git a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py index bc8ff080f0..da98c4816b 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py @@ -9,17 +9,16 @@ RULES = """\ -1. Use the `thought` parameter on EVERY tool call to state your reasoning. +1. Use the `thought` parameter on EVERY tool call. It is shown to the user, so write it as a brief user-facing status (e.g. "Checking existing pages" not "Calling list_pages to get page IDs"). Never use tool names or internal references. 2. Have tools → call them. No tools in current mode → check other modes before saying something is not possible. If another mode has the tool, switch_mode and use it. Only explain manual UI steps if no mode covers the action. 3. One tool per turn. Wait for the result. Never reply and call a tool in same turn. -4. Verify after create/modify — navigate to show the result. -5. Request priority: action > follow-up (reuse prior IDs, never search docs) > question. When a tool result contains next_steps, act on them immediately — do not ask for permission to continue. -6. You start in the mode matching your UI context (database/application/automation). If the user asks a how-to or feature question, call switch_mode("explain"), then search_user_docs. -7. After finishing the tool calls in a different mode (not just after switching — after the actual work is done and results received), switch back to the original domain mode (check and ). -8. Reply in concise Markdown. Never expose raw JSON or internal IDs unless asked. -9. When a request references resources by name/ID, verify they exist (list_*) before building on them. If not found, ask — don't guess. But when the task *requires* creating resources in another domain (e.g. building an app that needs new tables), switch_mode and create them yourself — don't ask the user to do it manually. -10. Before responding to the user, verify ALL parts of `` are addressed. If anything is missing, continue working. -11. Before adding a table to a database or a page to an application, check that the target is semantically related. If the name/purpose doesn't match, ask the user which target to use or whether to create a new one. Examples of mismatches: adding "Inquiries" table to a "Project Management" DB; adding "Event Registration" pages to a "Portfolio Website" app. This applies to ALL resource creation — tables, pages, and the applications/databases themselves. Remember their answer — only re-ask when a new, different mismatch arises. +4. Request priority: action > follow-up (reuse prior IDs, never search docs) > question. When a tool result contains next_steps, act on them immediately — do not ask for permission to continue. +5. You start in the mode matching your UI context (database/application/automation). If the user asks a how-to or feature question, call switch_mode("explain"), then search_user_docs. +6. After finishing the tool calls in a different mode (not just after switching — after the actual work is done and results received), switch back to the original domain mode (check and ). +7. Reply in concise Markdown. Never expose raw JSON or internal IDs unless asked. +8. Before starting work, use list_* to understand what exists and avoid duplicates. But don't list resources you just created — create_* tools already return IDs and refs. When a request references resources by name/ID, verify they exist before building on them. If not found, ask — don't guess. But when the task *requires* creating resources in another domain (e.g. building an app that needs new tables), switch_mode and create them yourself — don't ask the user to do it manually. +9. Before responding to the user, verify ALL parts of `` are addressed. If anything is missing, continue working. +10. At the start, verify the request fits the current UI context (e.g. don't add "Inquiries" table to a "Project Management" DB). If it doesn't match and not explicitly requested, ask the user which target to use. """ @@ -37,6 +36,7 @@ Workspace → Databases, Applications, Automations, Dashboards Database → Tables → Fields (30+ types, link_row for relations) + Views (grid, form, kanban, calendar, gallery, timeline) + Rows Application → Pages → Elements + Data Sources + Actions +Shared elements: Headers/footers live on a shared page and appear on ALL pages. ONLY put site-wide navigation in them (menus, logo, links). NEVER put page-specific content inside headers/footers. Automation → Workflows → Trigger + Action/Router/Iterator nodes (use {{ node.ref }} for formulas) """ diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py index 5338ddf07b..cd23eb3307 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py @@ -364,7 +364,6 @@ def delete_nodes( automation_toolset = FunctionToolset(TOOL_FUNCTIONS, max_retries=3) ROUTING_RULES = """\ -- Check list_* before create_* to avoid duplicates. - switch_mode: switch domain if task needs tools not in the current mode. - create_workflows: use {{ node.ref }} for node refs, $formula: prefix for dynamic field values. - add_nodes: insert/append nodes. Use list_nodes first to find existing node IDs.""" diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/__init__.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/__init__.py @@ -0,0 +1 @@ + diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/agents.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/agents.py new file mode 100644 index 0000000000..f662ac5044 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/agents.py @@ -0,0 +1,675 @@ +""" +Sub-agents for the builder assistant tools. + +Contains: +- ``BuilderFormulaContext``: Builder-specific formula context. +- ``update_element_formulas()``: Generates formulas for elements. +- ``update_data_source_formulas()``: Generates formulas for data sources. +- ``update_workflow_action_formulas()``: Generates formulas for workflow actions. +""" + +import json +from typing import TYPE_CHECKING, Any + +from django.db import transaction +from django.utils.translation import gettext as _ + +from loguru import logger + +from baserow.contrib.builder.data_sources.handler import DataSourceHandler +from baserow.contrib.builder.elements.handler import ElementHandler +from baserow.contrib.builder.elements.mixins import CollectionElementTypeMixin +from baserow.contrib.builder.elements.registries import element_type_registry +from baserow.contrib.builder.pages.models import Page +from baserow.contrib.builder.workflow_actions.signals import workflow_action_updated +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_ADVANCED, + BaserowFormulaObject, +) +from baserow.core.utils import to_path +from baserow_enterprise.assistant.tools.shared.agents import get_formula_generator +from baserow_enterprise.assistant.tools.shared.formula_utils import ( + create_example_from_json_schema, + minimize_json_schema, +) + +from .prompts import BUILDER_FORMULA_PROMPT +from .types import ( + ActionCreate, + DataSourceCreate, + DataSourceUpdate, + ElementItemCreate, + ElementUpdate, +) + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + + from baserow_enterprise.assistant.deps import ToolHelpers + + +# --------------------------------------------------------------------------- +# BuilderFormulaContext +# --------------------------------------------------------------------------- + + +class BuilderFormulaContext: + """ + Context for formula generation in builder elements. + + Provides access to data sources, page parameters, current record + (for repeat/table elements), form data, and user context. + """ + + def __init__(self, page: Page): + self.page = page + self.context: dict[str, Any] = {} + self.context_metadata: dict[str, Any] = {} + self._current_record_stack: list[int] = [] + + def load_page_context(self) -> None: + """Load all available data providers into the formula context.""" + + self._load_data_sources() + self._load_data_source_context() + self._load_page_parameters() + self._load_user_context() + + elements = ElementHandler().get_elements(self.page, use_cache=False) + self._load_form_data(elements) + + # -- Private loaders ---------------------------------------------------- + + def _load_data_sources(self) -> None: + """Load data sources with schemas.""" + + data_sources = DataSourceHandler().get_data_sources(self.page, with_shared=True) + for ds in data_sources: + if not ds.service: + continue + + service = ds.service.specific + service_type = service.get_type() + + try: + schema = service_type.generate_schema(service) + except Exception: + continue + if not schema: + continue + + key = f"data_source.{ds.id}" + try: + example = create_example_from_json_schema(schema) + fields = minimize_json_schema(schema) + except (ValueError, KeyError): + continue + + self.context[key] = example + self.context_metadata[key] = { + "name": ds.name, + "returns_list": service_type.returns_list, + "fields": fields, + } + + def _load_page_parameters(self) -> None: + """Load page path and query parameters.""" + + params: dict[str, str] = {} + params_meta: dict[str, dict] = {} + + for source in (self.page.path_params or [], self.page.query_params or []): + for p in source: + name = p.get("name") if isinstance(p, dict) else p + ptype = p.get("type", "text") if isinstance(p, dict) else "text" + params[name] = "example_value" + params_meta[name] = {"type": ptype} + + if params: + self.context["page_parameter"] = params + self.context_metadata["page_parameter"] = params_meta + + def _load_data_source_context(self) -> None: + """Load metadata (total_count) for list data sources.""" + + ds_ctx: dict[str, dict] = {} + ds_ctx_meta: dict[str, dict] = {} + + for key, meta in self.context_metadata.items(): + if not key.startswith("data_source.") or not meta.get("returns_list"): + continue + ds_id = key.replace("data_source.", "") + ds_ctx[ds_id] = {"total_count": 100} + ds_ctx_meta[ds_id] = { + "name": meta.get("name", ""), + "fields": {"total_count": {"type": "number", "desc": "Total records"}}, + } + + if ds_ctx: + self.context["data_source_context"] = ds_ctx + self.context_metadata["data_source_context"] = ds_ctx_meta + + def _load_form_data(self, elements: list) -> None: + """Load form element metadata.""" + + from baserow.contrib.builder.elements.mixins import FormElementTypeMixin + + form_data: dict[str, str] = {} + form_meta: dict[str, dict] = {} + + type_map = { + "input_text": "string", + "choice": "string", + "checkbox": "boolean", + "datetime_picker": "string", + "record_selector": "number", + } + + for element in elements: + el_type = element.get_type() + if not isinstance(el_type, FormElementTypeMixin): + continue + + el = element.specific + label = getattr(el, "label", None) + if label and isinstance(label, dict) and "formula" in label: + label = label["formula"] + label = str(label) if label else el_type.type + + data_type = type_map.get(el_type.type, "string") + form_data[str(el.id)] = data_type + form_meta[str(el.id)] = { + "type": el_type.type, + "data_type": data_type, + "label": label, + } + + if form_data: + self.context["form_data"] = form_data + self.context_metadata["form_data"] = form_meta + + def _load_user_context(self) -> None: + """Load user data provider context.""" + + self.context["user"] = { + "id": 1, + "email": "user@example.com", + "username": "user", + "role": "member", + "is_authenticated": True, + } + self.context_metadata["user"] = { + "id": {"type": "number", "desc": "User ID"}, + "email": {"type": "text", "desc": "User email address"}, + "username": {"type": "text", "desc": "Username"}, + "role": {"type": "text", "desc": "User role"}, + "is_authenticated": { + "type": "boolean", + "desc": "Whether user is logged in", + }, + } + + # -- Current record stack ----------------------------------------------- + + def push_current_record_context(self, data_source_id: int) -> None: + """Add current_record context for elements inside collections.""" + + self._current_record_stack.append(data_source_id) + ds_key = f"data_source.{data_source_id}" + + if ds_key in self.context_metadata: + example = self.context.get(ds_key, {}) + if isinstance(example, list) and example: + example = example[0] + self.context["current_record"] = example + ds_meta = self.context_metadata[ds_key] + self.context_metadata["current_record"] = { + "desc": "Current row in the collection element. " + "Use current_record.field_ for row values.", + **ds_meta.get("fields", {}), + } + + def pop_current_record_context(self) -> None: + """Remove current_record context when exiting a collection.""" + + if self._current_record_stack: + self._current_record_stack.pop() + + if not self._current_record_stack: + self.context.pop("current_record", None) + self.context_metadata.pop("current_record", None) + else: + prev = self._current_record_stack[-1] + self.push_current_record_context(prev) + self._current_record_stack.pop() + + # -- FormulaContext interface ------------------------------------------- + + def get_formula_context(self) -> dict[str, Any]: + """Return the context dict for formula generation.""" + return self.context + + def get_context_metadata(self) -> dict[str, Any]: + """Return metadata about the context.""" + return self.context_metadata + + def __getitem__(self, key: str) -> Any: + """ + Resolve a dotted path through the context. + + Handles compound keys like ``data_source.5.0.field_name`` and + wildcard ``*`` for array expansion. + """ + + parts = to_path(key) + if not parts: + raise KeyError(f"Empty path: {key}") + + value, remaining = self._resolve_root(key, parts) + value = self._traverse_path(key, value, remaining) + return self._coerce_leaf(value, key) + + # -- __getitem__ helpers ------------------------------------------------ + + def _resolve_root(self, key: str, parts: list[str]) -> tuple[Any, list[str]]: + """Resolve the root segment, handling ``data_source.{id}`` compound keys.""" + + if len(parts) >= 2 and parts[0] == "data_source": + ds_key = f"data_source.{parts[1]}" + if ds_key not in self.context: + raise KeyError( + f"Data source '{parts[1]}' not found. " + f"Available: {[k for k in self.context if k.startswith('data_source.')]}" + ) + return self.context[ds_key], parts[2:] + return self.context, parts + + def _traverse_path(self, key: str, value: Any, parts: list[str]) -> Any: + """Walk through *parts*, handling dicts, lists, and ``*`` wildcards.""" + + for i, part in enumerate(parts): + if isinstance(value, dict): + if part not in value: + raise KeyError(f"Key '{part}' not found in context at '{key}'") + value = value[part] + elif isinstance(value, list): + if part == "*": + return self._expand_wildcard(value, parts[i + 1 :]) + try: + idx = int(part) + except ValueError: + raise KeyError(f"Invalid list index '{part}' at '{key}'") + if idx >= len(value): + raise KeyError( + f"Index {idx} out of range (len {len(value)}) at '{key}'" + ) + value = value[idx] + else: + raise KeyError(f"Cannot traverse at '{part}' in '{key}'") + return value + + @staticmethod + def _expand_wildcard(items: list, rest: list[str]) -> str: + """Expand a ``*`` wildcard over *items*, extracting the remaining path.""" + + if not rest: + return json.dumps(items) + results = [] + for item in items: + v = item + for r in rest: + if isinstance(v, dict) and r in v: + v = v[r] + else: + v = None + break + if v is not None: + results.append(str(v)) + return ",".join(results) + + @staticmethod + def _coerce_leaf(value: Any, key: str = "") -> Any: + """Ensure the leaf value is a JSON-serialisable primitive.""" + + from datetime import date, datetime + + if isinstance(value, (list, dict)): + return json.dumps(value) + if not isinstance(value, (int, float, str, bool, date, datetime, type(None))): + raise ValueError( + f"Value for '{key}' is not a primitive. Got {type(value).__name__}." + ) + return value + + +# --------------------------------------------------------------------------- +# Formula update orchestrators +# --------------------------------------------------------------------------- + + +def update_element_formulas( + user: "AbstractUser", + page: Page, + elements: list[ElementItemCreate], + element_mapping: dict[str, tuple[Any, ElementItemCreate]], + tool_helpers: "ToolHelpers", +) -> list[str]: + """Generate and apply formulas for elements that need them. + + Returns a list of error messages for elements whose formulas could + not be generated (empty list on full success). + """ + + errors: list[str] = [] + context = BuilderFormulaContext(page) + context.load_page_context() + generate_formulas = get_formula_generator(BUILDER_FORMULA_PROMPT) + + for el_create in elements: + ref = el_create.ref + if ref not in element_mapping: + continue + + orm_element, _el_create = element_mapping[ref] + + # Push collection context if inside a repeat/table + pushed = False + try: + ancestor = ElementHandler().get_first_ancestor_of_type( + orm_element.id, CollectionElementTypeMixin + ) + except KeyError: + # Parent element may be on a different page (e.g. shared page header) + ancestor = None + if ancestor: + context.push_current_record_context(ancestor.data_source_id) + pushed = True + elif ( + isinstance( + element_type_registry.get(el_create.type), CollectionElementTypeMixin + ) + and hasattr(orm_element, "data_source_id") + and orm_element.data_source_id + ): + context.push_current_record_context(orm_element.data_source_id) + pushed = True + + try: + formulas = el_create.get_formulas_to_create(orm_element, context) + if formulas: + tool_helpers.update_status( + _("Generating formulas for element '%(ref)s'...") % {"ref": ref} + ) + with transaction.atomic(): + try: + generated = generate_formulas(formulas, context) + if generated: + el_create.update_with_formulas(user, orm_element, generated) + except Exception as exc: + logger.error( + "Failed to generate formulas for element {}: {}", + orm_element.id, + exc, + ) + errors.append(f"Formula generation failed for '{ref}': {exc}") + finally: + if pushed: + context.pop_current_record_context() + + return errors + + +def update_data_source_formulas( + user: "AbstractUser", + page: Page, + ds_pairs: list[tuple[Any, DataSourceCreate]], + tool_helpers: "ToolHelpers", +) -> list[str]: + """Generate and apply formulas for data sources that need them. + + Returns a list of error messages for data sources whose formulas could + not be generated (empty list on full success). + """ + + errors: list[str] = [] + if not ds_pairs: + return errors + + context = BuilderFormulaContext(page) + context.load_page_context() + generate_formulas = get_formula_generator(BUILDER_FORMULA_PROMPT) + + for orm_ds, ds_create in ds_pairs: + try: + formulas = ds_create.get_formulas_to_create(orm_ds, context) + if formulas: + tool_helpers.update_status( + _("Generating formulas for data source '%(name)s'...") + % {"name": ds_create.name} + ) + with transaction.atomic(): + try: + generated = generate_formulas(formulas, context) + if generated: + ds_create.update_with_formulas(user, orm_ds, generated) + except Exception as exc: + logger.error( + "Failed to generate formulas for data source {}: {}", + orm_ds.id, + exc, + ) + errors.append( + f"Formula generation failed for data source " + f"'{ds_create.name}': {exc}" + ) + except Exception as exc: + logger.error( + "Error processing data source {} for formulas: {}", orm_ds.id, exc + ) + errors.append(f"Error processing data source '{ds_create.name}': {exc}") + + return errors + + +def update_single_data_source_formulas( + user: "AbstractUser", + page: Page, + orm_ds: Any, + ds_update: DataSourceUpdate, + tool_helpers: "ToolHelpers", +) -> None: + """Generate and apply formulas for a single updated data source.""" + + from baserow.contrib.builder.data_sources.handler import DataSourceHandler + from baserow.contrib.builder.data_sources.service import DataSourceService + from baserow.core.services.registries import service_type_registry + + context = BuilderFormulaContext(page) + context.load_page_context() + + formulas = ds_update.get_formulas_to_update(orm_ds, context) + if not formulas: + return + + tool_helpers.update_status( + _("Generating formulas for data source %(id)d...") + % {"id": ds_update.data_source_id} + ) + generate_formulas = get_formula_generator(BUILDER_FORMULA_PROMPT) + with transaction.atomic(): + try: + generated = generate_formulas(formulas, context) + if generated: + service_kwargs: dict[str, Any] = {} + if "row_id" in generated: + service_kwargs["row_id"] = BaserowFormulaObject.create( + generated["row_id"], mode=BASEROW_FORMULA_MODE_ADVANCED + ) + if "search_query" in generated: + service_kwargs["search_query"] = BaserowFormulaObject.create( + generated["search_query"], + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if service_kwargs: + ds_for_update = DataSourceHandler().get_data_source_for_update( + orm_ds.id + ) + service_type = service_type_registry.get_by_model( + ds_for_update.service.specific + ) + DataSourceService().update_data_source( + user, + ds_for_update, + service_type=service_type, + **service_kwargs, + ) + except Exception as exc: + logger.exception( + "Failed to generate formulas for data source {}: {}", + orm_ds.id, + exc, + ) + + +def update_single_element_formulas( + user: "AbstractUser", + page: Page, + orm_element: Any, + element_update: ElementUpdate, + element_type: str, + tool_helpers: "ToolHelpers", +) -> None: + """Generate and apply formulas for a single updated element.""" + + from baserow.contrib.builder.elements.service import ElementService + + context = BuilderFormulaContext(page) + context.load_page_context() + + # Push collection context if inside a repeat/table + pushed = False + try: + ancestor = ElementHandler().get_first_ancestor_of_type( + orm_element.id, CollectionElementTypeMixin + ) + except KeyError: + # Parent element may be on a different page (e.g. shared page header) + ancestor = None + if ancestor: + context.push_current_record_context(ancestor.data_source_id) + pushed = True + elif ( + isinstance(element_type_registry.get(element_type), CollectionElementTypeMixin) + and hasattr(orm_element, "data_source_id") + and orm_element.data_source_id + ): + context.push_current_record_context(orm_element.data_source_id) + pushed = True + + try: + formulas = element_update.get_formulas_to_update( + orm_element, context, element_type + ) + if formulas: + tool_helpers.update_status( + _("Generating formulas for element %(id)d...") + % {"id": element_update.element_id} + ) + generate_formulas = get_formula_generator(BUILDER_FORMULA_PROMPT) + with transaction.atomic(): + try: + generated = generate_formulas(formulas, context) + if generated: + kwargs = {} + for field_name, formula in generated.items(): + if "." not in field_name and hasattr( + orm_element, field_name + ): + kwargs[field_name] = BaserowFormulaObject.create( + formula, + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if kwargs: + ElementService().update_element(user, orm_element, **kwargs) + except Exception as exc: + logger.exception( + "Failed to generate formulas for element {}: {}", + orm_element.id, + exc, + ) + finally: + if pushed: + context.pop_current_record_context() + + +def update_workflow_action_formulas( + user: "AbstractUser", + page: Page, + action_pairs: list[tuple[Any, ActionCreate]], + tool_helpers: "ToolHelpers", +) -> list[str]: + """Generate and apply formulas for workflow actions that need them. + + Returns a list of error messages for actions whose formulas could + not be generated (empty list on full success). + """ + + errors: list[str] = [] + if not action_pairs: + return errors + + context = BuilderFormulaContext(page) + context.load_page_context() + generate_formulas = get_formula_generator(BUILDER_FORMULA_PROMPT) + + for orm_action, action_create in action_pairs: + pushed = False + try: + formulas = action_create.get_formulas_to_create(orm_action, context) + + ancestor = ElementHandler().get_first_ancestor_of_type( + orm_action.element_id, CollectionElementTypeMixin + ) + if ancestor: + context.push_current_record_context(ancestor.data_source_id) + pushed = True + + if formulas: + ref = ( + action_create.element + if isinstance(action_create.element, str) + else f"element_{orm_action.element_id}" + ) + tool_helpers.update_status( + _("Generating formulas for action on '%(ref)s'...") % {"ref": ref} + ) + with transaction.atomic(): + try: + generated = generate_formulas(formulas, context) + if generated: + action_create.update_with_formulas(orm_action, generated) + orm_action.refresh_from_db() + workflow_action_updated.send( + None, workflow_action=orm_action, user=user + ) + except Exception as exc: + logger.error( + "Failed to generate formulas for action {}: {}", + orm_action.id, + exc, + ) + errors.append( + f"Formula generation failed for action on '{ref}': {exc}" + ) + except Exception as exc: + logger.error( + "Error processing action {} for formulas: {}", orm_action.id, exc + ) + errors.append( + f"Error processing action on element '{action_create.element}': {exc}" + ) + finally: + if pushed: + context.pop_current_record_context() + + return errors diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/helpers.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/helpers.py new file mode 100644 index 0000000000..fed32b3260 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/helpers.py @@ -0,0 +1,987 @@ +""" +Shared helpers for the builder assistant tools. + +Contains permission-checked accessors, listing functions, and the element/data +source/action creation orchestrators used by ``tools.py`` and ``agents.py``. +""" + +from typing import TYPE_CHECKING, Any + +from django.contrib.auth.models import AbstractUser + +from baserow.contrib.builder.data_sources.handler import DataSourceHandler +from baserow.contrib.builder.data_sources.service import DataSourceService +from baserow.contrib.builder.elements.exceptions import ElementDoesNotExist +from baserow.contrib.builder.elements.handler import ElementHandler +from baserow.contrib.builder.elements.registries import element_type_registry +from baserow.contrib.builder.elements.service import ElementService +from baserow.contrib.builder.models import Builder +from baserow.contrib.builder.operations import ListPagesBuilderOperationType +from baserow.contrib.builder.pages.handler import PageHandler +from baserow.contrib.builder.pages.models import Page +from baserow.contrib.builder.pages.service import PageService +from baserow.contrib.builder.workflow_actions.registries import ( + builder_workflow_action_type_registry, +) +from baserow.contrib.builder.workflow_actions.service import ( + BuilderWorkflowActionService, +) +from baserow.core.handler import CoreHandler +from baserow.core.integrations.models import Integration +from baserow.core.integrations.registries import integration_type_registry +from baserow.core.integrations.service import IntegrationService +from baserow.core.models import Workspace +from baserow.core.service import CoreService +from baserow.core.services.registries import service_type_registry + +from .types import ( + ActionCreate, + ActionItem, + DataSourceCreate, + DataSourceItem, + DataSourceUpdate, + ElementItem, + ElementItemCreate, + ElementMove, + ElementStyleUpdate, + ElementUpdate, + PageCreate, + PageItem, + PageUpdate, +) + +if TYPE_CHECKING: + pass + + +# --------------------------------------------------------------------------- +# Accessors +# --------------------------------------------------------------------------- + + +def get_builder( + user: AbstractUser, workspace: Workspace, application_id: int +) -> Builder: + """Get a builder application scoped to the user's workspace.""" + + from baserow.core.service import CoreService + + try: + return CoreService().get_application( + user, + application_id, + base_queryset=Builder.objects.filter(workspace=workspace), + ) + except Exception: + raise ToolInputError( + f"Application with ID {application_id} not found in this workspace. " + "Use list_builders to find valid application IDs." + ) + + +class ToolInputError(Exception): + """Raised when tool input is invalid — returned to the model as an error message.""" + + +def get_page(user: AbstractUser, page_id: int) -> Page: + """Get a page with permission check.""" + + try: + return PageService().get_page(user, page_id) + except Exception: + raise ToolInputError( + f"Page with ID {page_id} not found or not accessible. " + "Use list_pages to find valid page IDs." + ) + + +def get_local_baserow_integration(user: AbstractUser, builder: Builder) -> Integration: + """Get or create the LocalBaserow integration for a builder.""" + + integrations = IntegrationService().get_integrations(user, builder) + for integration in integrations: + if integration.get_type().type == "local_baserow": + return integration.specific + + local_baserow_type = integration_type_registry.get("local_baserow") + return IntegrationService().create_integration( + user, local_baserow_type, builder, name="Local Baserow" + ) + + +# --------------------------------------------------------------------------- +# Listing +# --------------------------------------------------------------------------- + + +def list_pages(user: AbstractUser, builder: Builder) -> list[PageItem]: + """List all non-shared pages in a builder.""" + pages = CoreHandler().filter_queryset( + user, + ListPagesBuilderOperationType.type, + PageHandler().get_pages(builder, Page.objects.filter(shared=False)), + workspace=builder.workspace, + ) + return [PageItem.from_orm(p) for p in pages] + + +def list_data_sources(user: AbstractUser, page: Page) -> list[DataSourceItem]: + """List all data sources on a page.""" + return [ + DataSourceItem.from_orm(ds) + for ds in DataSourceService().get_data_sources(user, page) + ] + + +def list_elements(user: AbstractUser, page: Page) -> list[ElementItem]: + """List all elements on a page, including shared elements visible on it.""" + elements = list(ElementService().get_elements(user, page)) + + # Also include shared elements (headers/footers) visible on this page + if not page.shared: + shared_page = page.builder.shared_page + shared_elements = ElementService().get_elements(user, shared_page) + elements = list(shared_elements) + elements + + return [ElementItem.from_orm(el) for el in elements] + + +def list_workflow_actions(user: AbstractUser, page: Page) -> list[ActionItem]: + """List all workflow actions on a page.""" + return [ + ActionItem.from_orm(a) + for a in BuilderWorkflowActionService().get_workflow_actions(user, page) + ] + + +# --------------------------------------------------------------------------- +# Page creation +# --------------------------------------------------------------------------- + + +def create_page(user: AbstractUser, builder: Builder, page_create: PageCreate) -> Page: + """Create a page in a builder application.""" + + from baserow.contrib.builder.pages.service import PageService + + svc = PageService() + page = svc.create_page( + user, + builder, + page_create.name, + page_create.path, + path_params=[p.model_dump() for p in page_create.path_params], + query_params=[p.model_dump() for p in page_create.query_params], + ) + + # PageService.create_page doesn't accept visibility/role kwargs, + # so we update them separately if non-default. + update_kwargs: dict = {} + if page_create.visibility != "all": + update_kwargs["visibility"] = page_create.visibility + if page_create.role_type != "allow_all": + update_kwargs["role_type"] = page_create.role_type + if page_create.roles: + update_kwargs["roles"] = page_create.roles + if update_kwargs: + page = svc.update_page(user, page, **update_kwargs) + + return page + + +# --------------------------------------------------------------------------- +# Page update +# --------------------------------------------------------------------------- + + +def update_page( + user: AbstractUser, + page_update: PageUpdate, +) -> Page: + """ + Update an existing page by ID. + + Returns the updated page. + """ + + from baserow.contrib.builder.pages.service import PageService + + page = get_page(user, page_update.page_id) + kwargs = page_update.to_update_kwargs() + if kwargs: + PageService().update_page(user, page, **kwargs) + page.refresh_from_db() + return page + + +# --------------------------------------------------------------------------- +# Data source creation +# --------------------------------------------------------------------------- + + +def create_data_source( + user: AbstractUser, + page: Page, + ds_create: DataSourceCreate, + integration: Integration, +) -> tuple[Any, int]: + """Create a data source on a page.""" + + service_type = service_type_registry.get(ds_create.get_service_type()) + service_kwargs = ds_create.to_service_kwargs(user, page.builder.workspace) + service_kwargs["integration"] = integration + + data_source = DataSourceService().create_data_source( + user=user, + page=page, + name=ds_create.name, + service_type=service_type, + **service_kwargs, + ) + + # Add sortings + sortings = ds_create.get_sortings() + if sortings: + from baserow.contrib.integrations.local_baserow.models import ( + LocalBaserowTableServiceSort, + ) + + LocalBaserowTableServiceSort.objects.bulk_create( + [ + LocalBaserowTableServiceSort( + service=data_source.service, + field_id=s["field_id"], + order_by=s["order_by"], + order=i, + ) + for i, s in enumerate(sortings) + ] + ) + + return data_source, data_source.id + + +# --------------------------------------------------------------------------- +# Data source update +# --------------------------------------------------------------------------- + + +def update_data_source( + user: AbstractUser, + ds_update: DataSourceUpdate, + workspace: Any, +) -> tuple[Any, str]: + """ + Update an existing data source by ID. + + Returns ``(orm_data_source, service_type_str)``. + """ + + from baserow.core.services.registries import service_type_registry + + try: + ds = DataSourceHandler().get_data_source_for_update(ds_update.data_source_id) + except Exception: + raise ToolInputError( + f"Data source with ID {ds_update.data_source_id} not found. " + "Use list_data_sources to find valid data source IDs." + ) + + service_type = ( + service_type_registry.get_by_model(ds.service.specific) if ds.service else None + ) + kwargs = ds_update.to_update_kwargs(user, workspace) + if kwargs: + ds = DataSourceService().update_data_source( + user, ds, service_type=service_type, **kwargs + ) + + ds_type = ds.service.get_type().type if ds.service else "" + return ds, ds_type + + +# --------------------------------------------------------------------------- +# Element creation +# --------------------------------------------------------------------------- + + +def create_element( + user: AbstractUser, + page: Page, + element_create: ElementItemCreate, + ref_to_id_map: dict[str, int], + data_source_ref_to_id_map: dict[str, int], + shared_page_refs: set[str] | None = None, + before_id: int | None = None, +) -> tuple[Any, int, list]: + """ + Create an element on a page, resolving refs to IDs. + + Returns ``(orm_element, element_id, action_pairs)`` where + *action_pairs* is a list of ``(orm_action, action_create)`` tuples + produced by post-create hooks (e.g. table button columns). + """ + + if shared_page_refs is None: + shared_page_refs = set() + + element_type = element_type_registry.get(element_create.type) + + # Determine target page + use_shared_page = element_create.use_shared_page + parent = element_create.parent_element + if isinstance(parent, str) and parent in shared_page_refs: + use_shared_page = True + elif isinstance(parent, int): + # Check if the parent element lives on the shared page + try: + parent_el = ElementHandler().get_element(parent) + if parent_el.page.shared: + use_shared_page = True + except Exception: + pass + + target_page = page.builder.shared_page if use_shared_page else page + if use_shared_page: + shared_page_refs.add(element_create.ref) + + # Resolve data source ref to integer ID early so that to_orm_kwargs + # (e.g. _convert_table_fields) can look up the data source's table fields. + ds = element_create.data_source + if isinstance(ds, str) and ds in data_source_ref_to_id_map: + element_create.data_source = data_source_ref_to_id_map[ds] + + kwargs = element_create.to_orm_kwargs(user, target_page) + + # Resolve parent + if isinstance(parent, int): + kwargs["parent_element_id"] = parent + elif isinstance(parent, str): + if parent not in ref_to_id_map: + raise ValueError( + f"Parent ref '{parent}' not found. " + "Define parent elements before children." + ) + kwargs["parent_element_id"] = ref_to_id_map[parent] + + if element_create.place_in_container: + kwargs["place_in_container"] = element_create.place_in_container + elif "parent_element_id" in kwargs: + try: + parent = ElementHandler().get_element(kwargs["parent_element_id"]) + if parent.get_type().type == "column": + kwargs["place_in_container"] = "0" + except Exception: + pass + + # Set data_source_id in kwargs (may already be set by to_orm_kwargs) + if isinstance(element_create.data_source, int): + kwargs["data_source_id"] = element_create.data_source + + before = None + if before_id is not None: + try: + before = ElementService().get_element(user, before_id) + except ElementDoesNotExist: + pass + + element = ElementService().create_element( + user, element_type, target_page, before=before, **kwargs + ) + + action_pairs = element_create.post_create(user, element, target_page) + return element, element.id, action_pairs + + +# --------------------------------------------------------------------------- +# Element update +# --------------------------------------------------------------------------- + + +def move_element( + user: AbstractUser, + element_move: ElementMove, +) -> Any: + """ + Move an element to a new position/parent on its page. + + Returns the moved ORM element. + """ + + try: + element = ElementHandler().get_element_for_update(element_move.element_id) + except ElementDoesNotExist: + raise ToolInputError( + f"Element with ID {element_move.element_id} not found. " + "Use list_elements to find valid element IDs." + ) + + parent = None + if element_move.parent_element_id is not None: + try: + parent = ElementHandler().get_element(element_move.parent_element_id) + except ElementDoesNotExist: + raise ToolInputError( + f"Parent element with ID {element_move.parent_element_id} not found." + ) + + before = None + if element_move.before_id is not None: + try: + before = ElementHandler().get_element(element_move.before_id) + except ElementDoesNotExist: + raise ToolInputError( + f"Before element with ID {element_move.before_id} not found." + ) + + place = element_move.place_in_container or "" + + return ElementService().move_element(user, element, parent, place, before=before) + + +def update_element( + user: AbstractUser, + element_update: ElementUpdate, +) -> tuple[Any, str]: + """ + Update an existing element by ID. + + If the element is a header/footer and ``menu_items`` are provided, + automatically finds or creates a child menu element and sets the + items on it (headers are containers, not menus themselves). + + Returns ``(orm_element, element_type_str)``. + """ + + try: + element = ElementHandler().get_element_for_update(element_update.element_id) + except ElementDoesNotExist: + raise ToolInputError( + f"Element with ID {element_update.element_id} not found. " + "Use list_elements to find valid element IDs." + ) + + element_type = element.get_type().type + kwargs = element_update.to_update_kwargs(element_type) + if kwargs: + element = ElementService().update_element(user, element, **kwargs) + + # Headers/footers are containers — menu_items belong on a child menu. + if element_type in ("header", "footer") and element_update.menu_items: + _ensure_child_menu(user, element, element_update) + + return element, element_type + + +def _ensure_child_menu( + user: AbstractUser, + header_element: Any, + element_update: ElementUpdate, +) -> None: + """Find or create a menu element inside a header/footer, then set its items.""" + + import uuid + + handler = ElementHandler() + children = handler.get_elements(header_element.page) + menu_child = None + for child in children: + if ( + child.parent_element_id == header_element.id + and child.get_type().type == "menu" + ): + menu_child = child + break + + menu_items_orm = [ + { + "uid": str(uuid.uuid4()), + "type": "link", + "variant": "link", + "name": item.name, + "navigation_type": "page", + "navigate_to_page_id": item.page_id, + "target": "self", + } + for item in element_update.menu_items + ] + + if menu_child is not None: + ElementService().update_element(user, menu_child, menu_items=menu_items_orm) + else: + menu_type = element_type_registry.get("menu") + ElementService().create_element( + user, + menu_type, + header_element.page, + parent_element_id=header_element.id, + menu_items=menu_items_orm, + ) + + +# --------------------------------------------------------------------------- +# Element style update +# --------------------------------------------------------------------------- + + +def update_element_style( + user: AbstractUser, + style_update: ElementStyleUpdate, +) -> tuple[Any, str]: + """ + Update an element's visual styles (box model + theme overrides). + + Returns ``(orm_element, element_type_str)``. + """ + + try: + element = ElementHandler().get_element_for_update(style_update.element_id) + except ElementDoesNotExist: + raise ToolInputError( + f"Element with ID {style_update.element_id} not found. " + "Use list_elements to find valid element IDs." + ) + + element_type = element.get_type().type + existing_styles = getattr(element, "styles", None) or {} + kwargs = style_update.to_update_kwargs(element_type, existing_styles) + if kwargs: + element = ElementService().update_element(user, element, **kwargs) + return element, element_type + + +# --------------------------------------------------------------------------- +# Workflow action creation +# --------------------------------------------------------------------------- + + +def _resolve_event(element_id: int, event: str) -> str: + """ + Resolve a human-friendly event name to the actual event string for + any element type. + + For elements with button collection fields (e.g. ``TableElement``), + the LLM typically sends ``"click"`` or ``"_click"`` + which must be resolved to ``"{uid}_click"`` where ``uid`` is the + ``CollectionField.uid``. + + For all other elements (``ButtonElement``, ``FormContainerElement``, + etc.) the event is returned unchanged since they use static event + names (``"click"``, ``"submit"``, ``"after_login"``). + """ + + try: + element = ElementHandler().get_element(element_id).specific + except Exception: + return event + + # Check if the element has a `fields` relation to CollectionField + # (currently TableElement; works for any future collection element). + if not hasattr(element, "fields"): + return event + + button_fields = list(element.fields.filter(type="button").order_by("order")) + if not button_fields: + return event + + # Already a UUID-prefixed event — no resolution needed + if "_" in event: + prefix = event.rsplit("_", 1)[0] + button_uids = {str(bf.uid) for bf in button_fields} + if prefix in button_uids: + return event + + # "click" → match to the first (or only) button column + if event == "click": + return f"{button_fields[0].uid}_click" + + # "_click" → match by name (case-insensitive) + if event.endswith("_click"): + name = event[: -len("_click")].strip().lower() + for bf in button_fields: + if bf.name.strip().lower() == name: + return f"{bf.uid}_click" + + # Fallback: use the first button column when no name matches. + # This is intentional — the LLM may send an unrecognised event name + # (e.g. a typo) and we prefer a working action over an error. + return f"{button_fields[0].uid}_click" + + +def create_workflow_action( + user: AbstractUser, + page: Page, + action_create: ActionCreate, + element_ref_to_id_map: dict[str, int], + data_source_ref_to_id_map: dict[str, int], + integration: Integration | None = None, +) -> tuple[Any, int]: + """Create a workflow action attached to an element.""" + + # Resolve element + el = action_create.element + if isinstance(el, int): + element_id = el + elif isinstance(el, str): + if el not in element_ref_to_id_map: + raise ValueError(f"Element ref '{el}' not found.") + element_id = element_ref_to_id_map[el] + else: + raise ValueError("element is required for workflow actions.") + + action_type = builder_workflow_action_type_registry.get( + action_create.get_action_type() + ) + + # Resolve human-friendly event names (e.g. "click" → "{uid}_click" + # for collection elements with button fields). + event = _resolve_event(element_id, action_create.event) + + kwargs: dict[str, Any] = { + "page": page, + "element_id": element_id, + "event": event, + } + kwargs.update(action_create.to_orm_kwargs()) + + service_kwargs = action_create.to_service_kwargs(user, page.builder.workspace) + if service_kwargs is not None: + if integration: + service_kwargs["integration"] = integration + field_mappings = action_create.get_field_mappings() + if field_mappings: + service_kwargs["field_mappings"] = field_mappings + kwargs["service"] = service_kwargs + + # Resolve data source for refresh_data_source + ds = action_create.data_source + if isinstance(ds, str) and ds in data_source_ref_to_id_map: + kwargs["data_source_id"] = data_source_ref_to_id_map[ds] + elif isinstance(ds, int): + kwargs["data_source_id"] = ds + + action = BuilderWorkflowActionService().create_workflow_action( + user, action_type, **kwargs + ) + return action, action.id + + +def create_table_button_actions( + user: AbstractUser, + page: Page, + orm_element: Any, + element_create: ElementItemCreate, + integration: Integration | None = None, +) -> list[tuple[Any, Any]]: + """ + Create workflow actions for button columns in a table element. + + Maps button fields to their collection field UIDs and creates actions + with ``event="{uid}_click"``. + """ + + if not element_create.fields: + return [] + + collection_fields = list(orm_element.fields.order_by("order")) + created = [] + + for i, field_cfg in enumerate(element_create.fields or []): + if field_cfg.type != "button": + continue + if i >= len(collection_fields): + continue + + # Button columns don't have inline workflow_actions in the flat model, + # so this hook is a placeholder for future extension. + # The ab-tools-no-loaders branch used discriminated union button configs + # with embedded workflow_actions — we don't replicate that here since + # workflow actions are created separately via create_actions tool. + + return created + + +# --------------------------------------------------------------------------- +# Field mapping +# --------------------------------------------------------------------------- + + +def add_field_mapping_to_action( + user: AbstractUser, action_id: int, field_id: int, value_formula: str +) -> dict[str, Any]: + """Add or update a field mapping on a create_row/update_row workflow action.""" + + from baserow.contrib.integrations.local_baserow.models import ( + LocalBaserowTableServiceFieldMapping, + ) + from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_ADVANCED, + BaserowFormulaObject, + ) + + action = BuilderWorkflowActionService().get_workflow_action(user, action_id) + action_type = action.get_type().type + + if action_type not in ("create_row", "update_row"): + raise ValueError( + f"Cannot add field mappings to '{action_type}'. " + "Only create_row and update_row support field mappings." + ) + + service = action.service.specific + + existing = LocalBaserowTableServiceFieldMapping.objects_and_trash.filter( + service=service, field_id=field_id + ).first() + + if existing: + existing.value = BaserowFormulaObject.create( + value_formula, mode=BASEROW_FORMULA_MODE_ADVANCED + ) + existing.enabled = True + existing.save() + status = "updated" + else: + LocalBaserowTableServiceFieldMapping.objects.create( + service=service, + field_id=field_id, + value=BaserowFormulaObject.create( + value_formula, mode=BASEROW_FORMULA_MODE_ADVANCED + ), + enabled=True, + ) + status = "created" + + mappings = [ + { + "field_id": m.field_id, + "field_name": m.field.name, + "value": str(m.value) if m.value else "", + } + for m in service.field_mappings.all() + ] + + return {"status": status, "field_mappings": mappings} + + +# --------------------------------------------------------------------------- +# User source helpers +# --------------------------------------------------------------------------- + + +def create_users_table( + user: AbstractUser, + database_id: int, + workspace: Workspace, + roles: list[str], +): + """ + Create a new users table with Name, Email, Password, and Role fields. + + :returns: (table, field_map) where field_map has keys + "name", "email", "password", "role". + """ + + from baserow.contrib.database.fields.actions import CreateFieldActionType + from baserow.contrib.database.fields.models import Field + from baserow.contrib.database.models import Database + from baserow.contrib.database.table.actions import CreateTableActionType + + database = ( + CoreService() + .list_applications_in_workspace( + user, workspace, base_queryset=Database.objects.filter(id=database_id) + ) + .first() + ) + if not database: + raise ValueError(f"Database with ID {database_id} not found in this workspace.") + table, _ = CreateTableActionType.do(user, database, "Users", fill_example=False) + + # The table comes with a primary "Name" text field already + name_field = Field.objects.get(table=table, primary=True) + + email_field = CreateFieldActionType.do(user, table, "email", name="Email") + password_field = CreateFieldActionType.do(user, table, "password", name="Password") + role_field = CreateFieldActionType.do( + user, + table, + "single_select", + name="Role", + select_options=[{"value": r, "color": "blue"} for r in roles], + ) + + # Add example users, one per role + from baserow.contrib.database.rows.actions import CreateRowsActionType + + role_options = {opt.value: opt.id for opt in role_field.select_options.all()} + example_rows = [] + for i, role_name in enumerate(roles, start=1): + example_rows.append( + { + name_field.db_column: role_name, + email_field.db_column: f"{role_name.lower()}@example.com", + role_field.db_column: role_options.get(role_name), + } + ) + if example_rows: + CreateRowsActionType.do(user, table, example_rows, model=table.get_model()) + + return table, { + "name": name_field, + "email": email_field, + "password": password_field, + "role": role_field, + "hint": "Remember to set a password for each user to enable login!", + } + + +def resolve_existing_table( + user: AbstractUser, + workspace: Workspace, + table_id: int, +): + """ + Validate an existing table for use as a user source. + + Auto-detects email, name, password, and role fields by type and name. + Creates a password field if one doesn't exist. + + :returns: (table, field_map) with keys "name", "email", "password", + and optionally "role". + :raises ValueError: If required fields can't be detected. + """ + + from baserow.contrib.database.fields.actions import CreateFieldActionType + from baserow.contrib.database.fields.models import ( + EmailField, + Field, + LongTextField, + PasswordField, + SingleSelectField, + TextField, + ) + from baserow.core.db import specific_iterator + from baserow_enterprise.assistant.tools.database.helpers import filter_tables + + table = filter_tables(user, workspace).get(id=table_id) + + fields_qs = Field.objects.filter(table=table).order_by("order", "id") + fields = list(specific_iterator(fields_qs.select_related("content_type"))) + + field_map: dict[str, Any] = {} + + for field in fields: + name_lower = field.name.lower() + + if "email" not in field_map: + if isinstance(field, EmailField): + field_map["email"] = field + elif ( + isinstance(field, (TextField, LongTextField)) and "email" in name_lower + ): + field_map["email"] = field + + if "name" not in field_map: + if isinstance(field, TextField) and "name" in name_lower: + field_map["name"] = field + + if "password" not in field_map: + if isinstance(field, PasswordField): + field_map["password"] = field + + if "role" not in field_map: + if isinstance(field, SingleSelectField) and "role" in name_lower: + field_map["role"] = field + elif isinstance(field, TextField) and "role" in name_lower: + field_map["role"] = field + + # Fall back to primary field for name + if "name" not in field_map: + for field in fields: + if field.primary: + field_map["name"] = field + break + + missing = [] + if "email" not in field_map: + missing.append("email (EmailField or TextField with 'email' in name)") + if "name" not in field_map: + missing.append("name (TextField with 'name' in name, or a primary field)") + if missing: + raise ValueError( + f"Table '{table.name}' is missing required fields: {', '.join(missing)}" + ) + + # Create password field if missing + if "password" not in field_map: + field_map["password"] = CreateFieldActionType.do( + user, table, "password", name="Password" + ) + + return table, field_map + + +def create_user_source( + user: AbstractUser, + application: Builder, + name: str, + table, + field_map: dict, + integration: Integration, +): + """ + Create a Local Baserow user source with password authentication. + + :param field_map: Must have "name", "email", "password"; optionally "role". + :returns: The created user source. + """ + + from baserow.core.user_sources.registries import user_source_type_registry + from baserow.core.user_sources.service import UserSourceService + + us_type = user_source_type_registry.get("local_baserow") + + kwargs: dict[str, Any] = { + "name": name, + "table_id": table.id, + "integration": integration, + "email_field_id": field_map["email"].id, + "name_field_id": field_map["name"].id, + "auth_providers": [ + { + "type": "local_baserow_password", + "password_field_id": field_map["password"].id, + } + ], + } + + if "role" in field_map: + kwargs["role_field_id"] = field_map["role"].id + + return UserSourceService().create_user_source(user, us_type, application, **kwargs) + + +def create_login_page( + user: AbstractUser, builder: Builder, user_source_id: int +) -> Page: + """ + Create a login page with an auth_form element and set it as the + builder's login page. + """ + + from baserow.contrib.builder.elements.registries import element_type_registry + from baserow.contrib.builder.elements.service import ElementService + from baserow.contrib.builder.pages.service import PageService + from baserow.core.handler import CoreHandler + + page = PageService().create_page(user, builder, "Login", "/login") + + auth_form_type = element_type_registry.get("auth_form") + ElementService().create_element( + user, auth_form_type, page, user_source_id=user_source_id + ) + + CoreHandler().update_application(user, builder, login_page_id=page.id) + builder.refresh_from_db() + return page diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/prompts.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/prompts.py new file mode 100644 index 0000000000..7fa7d7c88f --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/prompts.py @@ -0,0 +1,51 @@ +""" +Builder-specific formula generation prompt. + +Used by ``agents.py`` to configure the formula generator agent. +""" + +from baserow_enterprise.assistant.tools.shared.formula_prompt import FORMULA_LANGUAGE + +BUILDER_FORMULA_PROMPT = ( + FORMULA_LANGUAGE + + """\ + +## Context: Application Builder + +In builder formulas, data is accessed through these providers: + +| Provider | Path format | Description | +|---|---|---| +| data_source | `data_source..field_` | Single-row data source field | +| data_source (list) | `data_source..0.field_` | First item of a list data source | +| data_source_context | `data_source_context..total_count` | List data source metadata | +| current_record | `current_record.field_` | Current row inside repeat/table elements | +| page_parameter | `page_parameter.` | URL page parameters | +| form_data | `form_data.` | Form input values | +| user | `user.email`, `user.id`, `user.username`, `user.role`, `user.is_authenticated` | Current user info | + +**Rules:** +1. Use context_metadata to find correct data source IDs and field IDs +2. Always use field_ format (e.g., field_123), NOT field names +3. Inside collection elements (table, repeat), use current_record for the row being rendered (e.g., get('current_record.field_123')). data_source..0 is the first row of the entire list — it does NOT change per row. +4. Skip fields marked with [optional] if no suitable data exists +5. If **feedback** is provided, use it to refine or correct the generated formulas +6. Return valid formulas that evaluate against the provided context + +**Example:** +Input: +fields_to_resolve: {"value": "the product name from the products data source"} +context: {"data_source.5": [{"id": 1, "field_123": "Widget A", "field_124": 29.99}]} +context_metadata: {"data_source.5": {"name": "Products", "returns_list": true, "fields": {"field_123": {"id": 123, "name": "Name", "type": "text"}, "field_124": {"id": 124, "name": "Price", "type": "number"}}}} +Output: +generated_formulas: {"value": "get('data_source.5.0.field_123')"} + +**Example (inside collection element — current_record in context):** +Input: +fields_to_resolve: {"page_param_0": "the id from the projects data source"} +context: {"data_source.5": [{"id": 1, "field_123": "Widget A"}, {"id": 2, "field_123": "Widget B"}], "current_record": {"id": 1, "field_123": "Widget A"}} +context_metadata: {"data_source.5": {"name": "Projects", "returns_list": true, "fields": {"field_123": {"id": 123, "name": "Name", "type": "text"}}}, "current_record": {"desc": "Current row in the collection element. Use current_record.field_ for row values.", "field_123": {"id": 123, "name": "Name", "type": "text"}}} +Output: +generated_formulas: {"page_param_0": "get('current_record.id')"} +""" +) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/tool_types.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/tool_types.py new file mode 100644 index 0000000000..9d2323d5ff --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/tool_types.py @@ -0,0 +1,20 @@ +from baserow_enterprise.assistant.tools.registries import AssistantToolType + + +class BuilderToolType(AssistantToolType): + type = "builder" + + def get_tool_functions(self): + from .tools import TOOL_FUNCTIONS + + return TOOL_FUNCTIONS + + def get_toolset(self): + from .tools import builder_toolset + + return builder_toolset + + def get_routing_rules(self): + from .tools import ROUTING_RULES + + return ROUTING_RULES diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/tools.py new file mode 100644 index 0000000000..162c2f02c6 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/tools.py @@ -0,0 +1,1530 @@ +""" +Builder assistant tool functions. + +All tools use the ``RunContext[AssistantDeps]`` + ``FunctionToolset`` pattern. +Page-scoped ref tracking helpers are defined at the top of this module. +""" + +from typing import Annotated, Any + +from django.db import transaction +from django.utils.translation import gettext as _ + +from pydantic import Field +from pydantic_ai import RunContext +from pydantic_ai.toolsets import FunctionToolset + +from baserow.contrib.builder.pages.handler import PageHandler +from baserow_enterprise.assistant.deps import AssistantDeps +from baserow_enterprise.assistant.types import BuilderPageNavigationType + +from . import agents, helpers +from .types import ( + ActionCreate, + CollectionElementCreate, + DataSourceCreate, + DataSourceUpdate, + DisplayElementCreate, + ElementItemCreate, + ElementMove, + ElementStyleUpdate, + ElementUpdate, + FormElementCreate, + LayoutElementCreate, + PageCreate, + PageItem, + PageUpdate, +) +from .types.user_source import UserSourceSetup + +# --------------------------------------------------------------------------- +# Page-scoped ref tracking +# --------------------------------------------------------------------------- + + +def _get_page_context(tool_helpers, page_id: int) -> dict: + """Get or create the ref-tracking context dict for a page.""" + key = f"builder_page_{page_id}" + if key not in tool_helpers.request_context: + tool_helpers.request_context[key] = { + "element_refs": {}, + "data_source_refs": {}, + } + return tool_helpers.request_context[key] + + +def _track_element_refs(tool_helpers, page_id: int, refs: dict[str, int]) -> None: + _get_page_context(tool_helpers, page_id)["element_refs"].update(refs) + + +def _get_element_refs(tool_helpers, page_id: int) -> dict[str, int]: + return _get_page_context(tool_helpers, page_id)["element_refs"].copy() + + +def _track_data_source_refs(tool_helpers, page_id: int, refs: dict[str, int]) -> None: + _get_page_context(tool_helpers, page_id)["data_source_refs"].update(refs) + + +def _get_data_source_refs(tool_helpers, page_id: int) -> dict[str, int]: + return _get_page_context(tool_helpers, page_id)["data_source_refs"].copy() + + +# --------------------------------------------------------------------------- +# Page tools +# --------------------------------------------------------------------------- + + +def list_pages( + ctx: RunContext[AssistantDeps], + application_id: Annotated[int, Field(description="The builder application ID.")], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + List all pages in an application builder. + + WHEN to use: Check existing pages, find page IDs, or verify page names before creating new ones. + WHAT it does: Lists all non-shared pages with their id, name, path, parameters, and visibility. + RETURNS: Pages, login_page_id, user_sources with table info, and available_roles. + DO NOT USE when: You already have the page IDs you need. + """ + + from baserow.core.user_sources.handler import UserSourceHandler + + user = ctx.deps.user + workspace = ctx.deps.workspace + tool_helpers = ctx.deps.tool_helpers + + builder = helpers.get_builder(user, workspace, application_id) + tool_helpers.update_status( + _("Listing pages in %(app_name)s...") % {"app_name": builder.name} + ) + + pages = helpers.list_pages(user, builder) + + user_sources = UserSourceHandler().get_user_sources(builder) + user_source_data = [] + for us in user_sources: + entry = {"id": us.id, "name": us.name} + specific = us.specific if hasattr(us, "specific") else us + if hasattr(specific, "table_id"): + entry["table_id"] = specific.table_id + user_source_data.append(entry) + + return { + "pages": [p.model_dump() for p in pages], + "login_page_id": builder.login_page_id, + "user_sources": user_source_data, + "available_roles": UserSourceHandler().get_all_roles_for_application(builder), + } + + +def create_pages( + ctx: RunContext[AssistantDeps], + application_id: Annotated[int, Field(description="The builder application ID.")], + pages: Annotated[list[PageCreate], Field(description="Pages to create.")], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Create pages in an application builder. + + WHEN to use: User wants new pages in a builder app. + WHAT it does: Creates pages with paths and parameters. Skips duplicates by name. + RETURNS: Created pages with id, name, path. + DO NOT USE when: Pages with those names already exist — check with list_pages first. + + ## Page Setup + - Each page needs a unique name and path. + - Use path parameters like :id for dynamic routes (e.g., '/products/:id'). + - Path params must be defined in path_params array with name and type. + + ## Navigation + After creating pages, add navigation links (menu items, link elements) so users can reach them. + """ + + user = ctx.deps.user + workspace = ctx.deps.workspace + tool_helpers = ctx.deps.tool_helpers + + if not pages: + return {"created_pages": []} + + builder = helpers.get_builder(user, workspace, application_id) + + # Skip duplicates + existing = {p.name.lower(): p for p in helpers.list_pages(user, builder)} + created_pages: list[PageItem] = [] + skipped_pages: list[PageItem] = [] + + with transaction.atomic(): + for page_create in pages: + tool_helpers.raise_if_cancelled() + ex = existing.get(page_create.name.lower()) + if ex: + skipped_pages.append(ex) + continue + tool_helpers.update_status( + _("Creating page %(name)s...") % {"name": page_create.name} + ) + page = helpers.create_page(user, builder, page_create) + created_pages.append(PageItem.from_orm(page)) + + if created_pages: + last = created_pages[-1] + tool_helpers.navigate_to( + BuilderPageNavigationType( + type="builder-page", + application_id=application_id, + page_id=last.id, + page_name=last.name, + ) + ) + + result: dict[str, Any] = { + "created_pages": [p.model_dump() for p in created_pages], + } + if skipped_pages: + result["existing_pages"] = [p.model_dump() for p in skipped_pages] + if created_pages: + result["next_steps"] = ( + "Pages created. Next: create data sources (create_data_sources), " + "then elements (create_display_elements, create_layout_elements, " + "create_form_elements, create_collection_elements), " + "then actions for buttons/forms (create_actions)." + ) + return result + + +# --------------------------------------------------------------------------- +# Page update tool +# --------------------------------------------------------------------------- + + +def update_page( + ctx: RunContext[AssistantDeps], + page: Annotated[PageUpdate, Field(description="Page update data.")], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Update an existing page's properties. + + WHEN to use: User wants to rename a page, change its path, or modify parameters. + WHAT it does: Updates the specified fields on a page. Only non-null fields are applied. + RETURNS: Updated page with id, name, path. + DO NOT USE when: You need to create a new page — use create_pages instead. + + ## Usage + - page_id: ID of the page to update (from list_pages). + - Only set the fields you want to change. + - When changing path, also update path_params if the new path has different parameters. + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + tool_helpers.update_status(_("Updating page %(id)d...") % {"id": page.page_id}) + + updated_page = helpers.update_page(user, page) + page_item = PageItem.from_orm(updated_page) + + return { + "status": "ok", + "page": page_item.model_dump(), + "updated_fields": page.get_updated_field_names(), + } + + +# --------------------------------------------------------------------------- +# Data source tools +# --------------------------------------------------------------------------- + + +def list_data_sources( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + List all data sources on a page. + + WHEN to use: Check existing data sources or find data source IDs. + WHAT it does: Lists data sources with id, name, type, table_id. + RETURNS: Data sources array. + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + page = helpers.get_page(user, page_id) + tool_helpers.update_status( + _("Listing data sources on %(name)s...") % {"name": page.name} + ) + + ds_list = helpers.list_data_sources(user, page) + return {"data_sources": [ds.model_dump() for ds in ds_list]} + + +def create_data_sources( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + data_sources: Annotated[ + list[DataSourceCreate], Field(description="Data sources to create.") + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Create data sources to connect page elements to database tables. + + WHEN to use: Page needs data from database tables for display or forms. + WHAT it does: Creates list_rows or get_row data sources. Skips duplicates by name. + RETURNS: Created data sources with ref-to-ID mapping. + DO NOT USE when: Data sources with those names already exist on the page. + + ## Data Source Types + - list_rows: Fetches multiple rows — use when the page displays a collection (table, repeat, dropdown). + - get_row: Fetches a single row by ID — use when the page works with one specific record. Set row_id with $formula: to get the ID dynamically (e.g. from a page parameter). + + ## Dynamic Values with $formula: + - get_row row_id: "$formula: the id from the page parameter" + - list_rows search_query: "$formula: the text from the search input" + + ## Filtering with view_id + To filter a list_rows data source, create a database table view with the + desired filters (using create_views + create_view_filters), then pass + its view_id here. The view's filters and sortings are applied automatically. + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + if not data_sources: + return {"created_data_sources": [], "ref_to_id_map": {}} + + page = helpers.get_page(user, page_id) + integration = helpers.get_local_baserow_integration(user, page.builder) + + existing_ds = helpers.list_data_sources(user, page) + existing_by_name = {ds.name.lower(): ds for ds in existing_ds} + ref_to_id: dict[str, int] = {} + created: list[dict] = [] + ds_pairs: list[tuple] = [] + + with transaction.atomic(): + for ds_create in data_sources: + tool_helpers.raise_if_cancelled() + ex = existing_by_name.get(ds_create.name.lower()) + if not ex: + ex = next( + (ds for ds in existing_ds if ds_create.matches_existing(ds)), None + ) + if ex: + ref_to_id[ds_create.ref] = ex.id + continue + + tool_helpers.update_status( + _("Creating data source '%(name)s'...") % {"name": ds_create.name} + ) + orm_ds, ds_id = helpers.create_data_source( + user, page, ds_create, integration + ) + ref_to_id[ds_create.ref] = ds_id + ds_pairs.append((orm_ds, ds_create)) + created.append( + { + "id": ds_id, + "ref": ds_create.ref, + "name": ds_create.name, + "type": ds_create.type, + } + ) + + # Formula generation (separate transactions) + errors = agents.update_data_source_formulas(user, page, ds_pairs, tool_helpers) + + _track_data_source_refs(tool_helpers, page_id, ref_to_id) + + result: dict[str, Any] = { + "created_data_sources": created, + "ref_to_id_map": ref_to_id, + } + if errors: + result["errors"] = errors + return result + + +# --------------------------------------------------------------------------- +# Data source update tool +# --------------------------------------------------------------------------- + + +def update_data_source( + ctx: RunContext[AssistantDeps], + page_id: Annotated[ + int, Field(description="The page ID the data source belongs to.") + ], + data_source: Annotated[ + DataSourceUpdate, Field(description="Data source update data.") + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Update an existing data source's properties. + + WHEN to use: User wants to change a data source's name, table, row_id, or search_query. + WHAT it does: Updates the specified fields on a data source. Only non-null fields are applied. + RETURNS: Updated data source ID and list of changed fields. + DO NOT USE when: You need to create a new data source — use create_data_sources instead. + + ## Usage + - data_source_id: ID of the data source to update (from list_data_sources). + - Only set the fields you want to change. + + ## Dynamic Values with $formula: + - row_id: "$formula: the id from the page parameter" + - search_query: "$formula: the text from the search input" + """ + + user = ctx.deps.user + workspace = ctx.deps.workspace + tool_helpers = ctx.deps.tool_helpers + + page = helpers.get_page(user, page_id) + tool_helpers.update_status( + _("Updating data source %(id)d...") % {"id": data_source.data_source_id} + ) + + with transaction.atomic(): + orm_ds, ds_type = helpers.update_data_source(user, data_source, workspace) + + # Handle formula generation for $formula: fields (separate transaction) + formulas = data_source.get_formulas_to_update(orm_ds, None) + if formulas: + agents.update_single_data_source_formulas( + user, page, orm_ds, data_source, tool_helpers + ) + + updated_fields = data_source.get_updated_field_names() + return { + "status": "ok", + "data_source_id": data_source.data_source_id, + "service_type": ds_type, + "updated_fields": updated_fields, + } + + +# --------------------------------------------------------------------------- +# Element tools +# --------------------------------------------------------------------------- + + +def list_elements( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + List all elements on a page. + + WHEN to use: Check existing elements, find element IDs or container structure. + WHAT it does: Lists elements with id, type, order, parent_element_id, is_container. + RETURNS: Elements array. + + Elements with page_name="[shared]" are headers/footers visible on ALL pages. + Do not add page-specific children to them. + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + page = helpers.get_page(user, page_id) + tool_helpers.update_status( + _("Listing elements on %(name)s...") % {"name": page.name} + ) + + elements = helpers.list_elements(user, page) + return {"elements": [el.model_dump() for el in elements]} + + +def _create_elements_internal( + ctx: RunContext[AssistantDeps], + page_id: int, + elements: list[ElementItemCreate], + before_element_id: int | None = None, +) -> dict[str, Any]: + """Shared implementation for all create_*_elements tools.""" + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + if not elements: + return {"created_elements": [], "ref_to_id_map": {}} + + page = helpers.get_page(user, page_id) + shared_page = PageHandler().get_shared_page(page.builder) + + tool_helpers.update_status( + _("Creating %(count)d elements on %(name)s...") + % {"count": len(elements), "name": page.name} + ) + + ref_to_id: dict[str, int] = _get_element_refs(tool_helpers, page_id) + element_mapping: dict[str, tuple[Any, ElementItemCreate]] = {} + ds_ref_to_id = _get_data_source_refs(tool_helpers, page_id) + shared_page_refs: set[str] = set( + _get_element_refs(tool_helpers, shared_page.id).keys() + ) + created: list[dict] = [] + + errors: list[str] = [] + table_action_pairs: list[tuple] = [] + with transaction.atomic(): + for el_create in elements: + tool_helpers.raise_if_cancelled() + try: + element, el_id, action_pairs = helpers.create_element( + user, + page, + el_create, + ref_to_id, + ds_ref_to_id, + shared_page_refs, + before_element_id, + ) + except (ValueError, Exception) as exc: + errors.append(f"{el_create.ref}: {exc}") + continue + ref_to_id[el_create.ref] = el_id + element_mapping[el_create.ref] = (element, el_create) + table_action_pairs.extend(action_pairs) + created.append({"id": el_id, "ref": el_create.ref, "type": el_create.type}) + + # Formula generation (separate transactions) + errors.extend( + agents.update_element_formulas( + user, page, elements, element_mapping, tool_helpers + ) + ) + + if table_action_pairs: + errors.extend( + agents.update_workflow_action_formulas( + user, page, table_action_pairs, tool_helpers + ) + ) + + _track_element_refs(tool_helpers, page_id, ref_to_id) + _track_element_refs( + tool_helpers, + shared_page.id, + {r: 0 for r in ref_to_id if r in shared_page_refs}, + ) + + result: dict[str, Any] = {"created_elements": created, "ref_to_id_map": ref_to_id} + + if errors: + result["errors"] = errors + + # Guide the model to create workflow actions for interactive elements + actionable = [ + el.ref for el in elements if el.type in ("button", "link", "form_container") + ] + if actionable: + result["next_steps"] = ( + f"Elements {actionable} need workflow actions. " + "Call create_actions next: 'click' event for buttons/links, " + "'submit' event for form_container." + ) + + # Navigate to the page containing the created elements + if created: + tool_helpers.navigate_to( + BuilderPageNavigationType( + type="builder-page", + application_id=page.builder_id, + page_id=page_id, + page_name=page.name, + ) + ) + + return result + + +def create_display_elements( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + elements: Annotated[ + list[DisplayElementCreate], Field(description="Display elements to create.") + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], + before_element_id: Annotated[ + int | None, + Field(default=None, description="Insert before this element ID."), + ] = None, +) -> dict[str, Any]: + """\ + Create display elements on a page: heading, text, button, link, image. + + PREREQUISITE: The page must already exist. Call create_pages first if it doesn't. + WHEN to use: User wants to add text content, headings, buttons, links, or images. + WHAT it does: Creates display elements with formula support for dynamic values. + RETURNS: Created elements with ref-to-ID mapping. + + ## Element Structure + - parent_element: int ID (existing container) or string ref (same batch) + - place_in_container: 0-indexed column position for column elements + + ## Dynamic Values with $formula: + - Heading/text value: "$formula: the product name from the products data source" + - Image URL: "$formula: the image URL from the product data source" + - Static text: use plain strings (auto-wrapped in quotes) + + ## After Creating Buttons/Links + Buttons/links need a click action (open_page, notification, etc.) via create_actions. + ALWAYS call create_actions after creating buttons or links. + + ## Buttons/Links in Shared Headers + - In shared headers, only use links that navigate to a FIXED page (navigate_to_page_id). + - Do NOT create "back" buttons or context-dependent navigation in shared headers. + - For page-specific navigation (back, contextual links), place buttons on the page itself. + """ + + internal = [el.to_element_item_create() for el in elements] + return _create_elements_internal(ctx, page_id, internal, before_element_id) + + +def create_layout_elements( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + elements: Annotated[ + list[LayoutElementCreate], Field(description="Layout elements to create.") + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], + before_element_id: Annotated[ + int | None, + Field(default=None, description="Insert before this element ID."), + ] = None, +) -> dict[str, Any]: + """\ + Create layout/navigation elements on a page: column, simple_container, header, footer, menu. + + WHEN to use: User wants page structure — columns, containers, headers, footers, menus. + WHAT it does: Creates container elements that hold child elements. + RETURNS: Created elements with ref-to-ID mapping. + + ## Element Structure + - Layout elements are containers — add children via parent_element ref. + - column: creates N columns, children use place_in_container "0", "1", etc. + - menu: add menu_items to link to pages. + + ## Shared Elements (header, footer) + - Headers/footers are shared across ALL pages by default (share_type="all"). + - Use share_type="only" + page_ids to limit which pages show them. + - ONLY put absolute navigation in shared headers: links/menus to specific pages. + - NEVER put page-specific content in shared headers (e.g., "back" button, + page-specific data, breadcrumbs). These vary per page and will be wrong. + - A menu element inside a header/footer is also shared. + - Shared elements CANNOT reference page-specific data sources. + """ + + internal = [el.to_element_item_create() for el in elements] + return _create_elements_internal(ctx, page_id, internal, before_element_id) + + +def create_form_elements( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + elements: Annotated[ + list[FormElementCreate], Field(description="Form elements to create.") + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], + before_element_id: Annotated[ + int | None, + Field(default=None, description="Insert before this element ID."), + ] = None, +) -> dict[str, Any]: + """\ + Create form elements on a page: form_container, input_text, choice, checkbox, datetime_picker, record_selector. + + WHEN to use: User wants a form to collect input data. + WHAT it does: Creates form containers and input elements with validation. + RETURNS: Created elements with ref-to-ID mapping. + + ## Form Structure + - Create a form_container first, then add inputs inside it via parent_element. + - Each input has label, placeholder, required, default_value. + - input_text: validation_type (any, email, integer), is_multiline. + - choice: choice_options, multiple. + - record_selector: needs data_source. + + ## Edit Forms + For forms that edit existing data, set default_value on each input using $formula: to reference the field value from the page's data source (e.g. "$formula: the Name field from the data source"). + The form's submit action should be update_row with row_id referencing the page parameter. + + ## After Creating Forms + Form containers need a submit action (create_row, update_row) via create_actions. + ALWAYS call create_actions after creating form_container elements. + """ + + internal = [el.to_element_item_create() for el in elements] + return _create_elements_internal(ctx, page_id, internal, before_element_id) + + +def create_collection_elements( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + elements: Annotated[ + list[CollectionElementCreate], + Field(description="Collection elements to create."), + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], + before_element_id: Annotated[ + int | None, + Field(default=None, description="Insert before this element ID."), + ] = None, +) -> dict[str, Any]: + """\ + Create collection elements on a page: table, repeat. + + WHEN to use: User wants to display data from a data source in a table or repeating layout. + WHAT it does: Creates collection elements connected to data sources. + RETURNS: Created elements with ref-to-ID mapping. + + ## Prerequisites + Create data sources first (create_data_sources), then reference them here. + + ## Table + - data_source: the data source ID or ref. + - fields: column configurations — always specify which columns to show. Each field has name, type ("text" or "button"), and value ($formula: for dynamic content). + + ## Repeat + - data_source: the data source ID or ref. + - orientation: "vertical" or "horizontal". + - Add child elements inside the repeat via parent_element ref. + """ + + internal = [el.to_element_item_create() for el in elements] + return _create_elements_internal(ctx, page_id, internal, before_element_id) + + +# --------------------------------------------------------------------------- +# Element update tool +# --------------------------------------------------------------------------- + + +def update_element( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID the element belongs to.")], + element: Annotated[ElementUpdate, Field(description="Element update data.")], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Update an existing element's properties. + + WHEN to use: User wants to change properties of an existing element (text, label, settings, etc.). + WHAT it does: Updates the specified fields on an element. Only non-null fields are applied. + RETURNS: Updated element ID and list of changed fields. + DO NOT USE when: You need to move elements, change data sources, or modify styles — use other tools for those. + + ## Usage + - element_id: ID of the element to update (from list_elements). + - Only set the fields you want to change — unset fields are left unchanged. + - Invalid fields for the element type are silently ignored. + + ## Dynamic Values with $formula: + - value: "$formula: the product name from the data source" + - default_value: "$formula: the current user's email" + + ## Menu Items + - To add/replace menu items on a menu element, set menu_items with the full list. + - Each item needs name (display text) and page_id (target page). + - This REPLACES all existing items — include existing items you want to keep. + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + page = helpers.get_page(user, page_id) + tool_helpers.update_status( + _("Updating element %(id)d...") % {"id": element.element_id} + ) + + with transaction.atomic(): + orm_element, element_type = helpers.update_element(user, element) + + # Handle formula generation for $formula: fields (separate transaction) + formulas = element.get_formulas_to_update(orm_element, None, element_type) + if formulas: + agents.update_single_element_formulas( + user, page, orm_element, element, element_type, tool_helpers + ) + + updated_fields = element.get_updated_field_names() + return { + "status": "ok", + "element_id": element.element_id, + "element_type": element_type, + "updated_fields": updated_fields, + } + + +# --------------------------------------------------------------------------- +# Element style tool +# --------------------------------------------------------------------------- + + +def update_element_style( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID the element belongs to.")], + style: Annotated[ElementStyleUpdate, Field(description="Style update data.")], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Update visual style of an element (box model + theme overrides). + + WHEN to use: User wants to change borders, padding, margins, background, width, or theme style overrides (button colors, typography, input styles, etc.). + WHAT it does: Applies box-model values and per-element-type theme overrides. + RETURNS: Updated element ID, type, and list of changed fields. + DO NOT USE when: You need to change content (text, label) or structural properties — use update_element instead. + + ## Box Model + - border_color, border_size, padding, margin: pass a single value for all 4 sides, or a dict like {"left": 0, "top": 10} to set specific sides only. + - border_radius, background_radius: corner rounding. + - background: "none" or "color", background_color: hex color. + - width: "full", "full-width", "normal", "medium", "small". + + ## Theme Style Overrides (per element type) + - button: background_color, text_color, border_color, hover colors, font_size, width, alignment. + - link: text_color, hover_text_color, font_size, font_weight. + - typography: heading_1_*/body_* text_color, font_size, font_weight, text_alignment. + - input: input_background_color, input_border_color, input_text_color, label_text_color. + - table: header/cell colors, border_color, border_size. + - image: image_alignment, max_width, max_height, border_radius. + + Only blocks valid for the element type are applied; others are ignored. + + ## Reset + Set reset=true to restore all box-model fields to defaults and clear theme overrides. + You can combine reset with new values to reset-then-apply. + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + helpers.get_page(user, page_id) + tool_helpers.update_status( + _("Styling element %(id)d...") % {"id": style.element_id} + ) + + with transaction.atomic(): + element, element_type = helpers.update_element_style(user, style) + + # Report which fields were explicitly set (not inherited from existing styles) + updated_fields = list(style.to_update_kwargs(element_type, {}).keys()) + if not updated_fields: + return { + "status": "warning", + "element_id": style.element_id, + "element_type": element_type, + "message": ( + "No style fields were applied. Make sure you pass style " + "properties (padding, margin, border_size, border_color, " + "background, width, etc.) in the style parameter. " + "Theme overrides (button, link, typography, etc.) are only " + "applied if the element type supports them." + ), + } + return { + "status": "ok", + "element_id": style.element_id, + "element_type": element_type, + "updated_fields": updated_fields, + } + + +# --------------------------------------------------------------------------- +# Element move tool +# --------------------------------------------------------------------------- + + +def move_elements( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + moves: Annotated[ + list[ElementMove], Field(description="Move operations to perform.") + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Move or reorder elements on a page. + + WHEN to use: User wants to reorder elements, move elements into/out of containers, or rearrange page layout. + WHAT it does: Moves each element to a new position, parent, or container slot. + RETURNS: List of moved elements with their new positions. + DO NOT USE when: You need to create new elements — use create_*_elements instead. + + ## Parameters per move + - element_id: ID of the element to move (from list_elements). + - before_id: Place before this element. null = move to end. + - parent_element_id: New parent container. null = move to root level. + - place_in_container: Container slot (e.g. "0", "1" for columns). null = default. + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + if not moves: + return {"moved_elements": []} + + page = helpers.get_page(user, page_id) + tool_helpers.update_status( + _("Moving %(count)d elements on %(name)s...") + % {"count": len(moves), "name": page.name} + ) + + moved: list[dict] = [] + errors: list[str] = [] + + for element_move in moves: + tool_helpers.raise_if_cancelled() + try: + with transaction.atomic(): + element = helpers.move_element(user, element_move) + moved.append( + { + "element_id": element.id, + "parent_element_id": element.parent_element_id, + "place_in_container": element.place_in_container, + "order": str(element.order), + } + ) + except Exception as exc: + errors.append(f"element {element_move.element_id}: {exc}") + + result: dict[str, Any] = {"moved_elements": moved} + if errors: + result["errors"] = errors + return result + + +# --------------------------------------------------------------------------- +# Workflow action tools +# --------------------------------------------------------------------------- + + +def list_actions( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + List all workflow actions on a page. + + WHEN to use: Check existing actions, find action IDs, or review field mappings. + WHAT it does: Lists actions with id, type, element_id, event, field_mappings. + RETURNS: Workflow actions array. + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + page = helpers.get_page(user, page_id) + tool_helpers.update_status( + _("Listing actions on %(name)s...") % {"name": page.name} + ) + + actions = helpers.list_workflow_actions(user, page) + return {"workflow_actions": [a.model_dump() for a in actions]} + + +def create_actions( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + actions: Annotated[ + list[ActionCreate], Field(description="Workflow actions to create.") + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Create workflow actions attached to elements (click, submit events). + + WHEN to use: User wants buttons/forms to perform actions (navigate, create/update rows, show notifications). + WHAT it does: Creates workflow actions with formula support for dynamic values. + RETURNS: Created actions with id, type, element_ref, event. + + ## Attaching Actions + - element_ref: attach to newly created element (auto-tracked) + - element_id: attach to existing element (from list_elements) + - event: "click" for buttons/links, "submit" for form containers + + ## Action Types + - notification: Show a message (title/description are formulas) + - open_page: Navigate to another page (set navigate_to_page_id). Use page_parameters to pass context like the current row's ID to the target page's path parameters. + - create_row: Insert a row (needs table_id and field_values) + - update_row: Update a row (needs table_id, row_id, field_values) + - delete_row: Delete a row (needs table_id and row_id) + - refresh_data_source: Reload a data source + - logout: Log out the user + + ## Dynamic Values with $formula: + Use "$formula: " — describe the data you want using references or ids when possible. + - field_values: {"field_id": "123", "value": "$formula: the Name form input"} + - row_id: "$formula: the id from the page parameter" + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + if not actions: + return {"created_actions": []} + + page = helpers.get_page(user, page_id) + integration = helpers.get_local_baserow_integration(user, page.builder) + + el_refs = _get_element_refs(tool_helpers, page_id) + ds_refs = _get_data_source_refs(tool_helpers, page_id) + created: list[dict] = [] + action_pairs: list[tuple] = [] + + errors: list[str] = [] + with transaction.atomic(): + for action_create in actions: + tool_helpers.raise_if_cancelled() + tool_helpers.update_status( + _("Creating %(type)s action...") % {"type": action_create.type} + ) + try: + orm_action, action_id = helpers.create_workflow_action( + user, page, action_create, el_refs, ds_refs, integration + ) + except (ValueError, Exception) as exc: + errors.append(f"{action_create.type} on {action_create.element}: {exc}") + continue + action_pairs.append((orm_action, action_create)) + created.append( + { + "id": action_id, + "type": action_create.type, + "element": action_create.element, + "event": action_create.event, + } + ) + + # Formula generation (separate transactions) + errors.extend( + agents.update_workflow_action_formulas(user, page, action_pairs, tool_helpers) + ) + + result: dict[str, Any] = {"created_actions": created} + if errors: + result["errors"] = errors + return result + + +def add_action_field_mapping( + ctx: RunContext[AssistantDeps], + action_id: Annotated[int, Field(description="The workflow action ID.")], + field_id: Annotated[int, Field(description="The target table field ID.")], + value_formula: Annotated[ + str, + Field( + description="Formula for the value, e.g. get('form_data.123') or get('page_parameter.id')." + ), + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Add or update a field mapping on an existing create_row/update_row action. + + WHEN to use: Adding a new form input to an existing form that already has a row action. + WHAT it does: Maps a table field to a formula value without recreating the action. + RETURNS: Updated field mappings list. + """ + + tool_helpers = ctx.deps.tool_helpers + tool_helpers.update_status( + _("Adding field mapping to action %(id)d...") % {"id": action_id} + ) + + return helpers.add_field_mapping_to_action( + ctx.deps.user, action_id, field_id, value_formula + ) + + +# --------------------------------------------------------------------------- +# Composite page setup — phase helpers +# --------------------------------------------------------------------------- + + +def _setup_data_sources( + user, + page, + data_sources: list, + ds_ref_to_id: dict[str, int], + integration, + tool_helpers, +) -> tuple[list[dict], list[str]]: + """Create data sources, skipping duplicates by name or structural match. + + Mutates *ds_ref_to_id* in place. Returns ``(created, errors)``. + """ + + created: list[dict] = [] + errors: list[str] = [] + if not data_sources: + return created, errors + + existing_ds = helpers.list_data_sources(user, page) + existing_by_name = {ds.name.lower(): ds for ds in existing_ds} + ds_pairs: list[tuple] = [] + + with transaction.atomic(): + for ds_create in data_sources: + tool_helpers.raise_if_cancelled() + ex = existing_by_name.get(ds_create.name.lower()) + if not ex: + ex = next( + (ds for ds in existing_ds if ds_create.matches_existing(ds)), + None, + ) + if ex: + ds_ref_to_id[ds_create.ref] = ex.id + continue + tool_helpers.update_status( + _("Creating data source '%(name)s'...") % {"name": ds_create.name} + ) + try: + orm_ds, ds_id = helpers.create_data_source( + user, page, ds_create, integration + ) + ds_ref_to_id[ds_create.ref] = ds_id + ds_pairs.append((orm_ds, ds_create)) + created.append( + { + "id": ds_id, + "ref": ds_create.ref, + "name": ds_create.name, + "type": ds_create.type, + } + ) + except Exception as exc: + errors.append(f"data_source {ds_create.ref}: {exc}") + errors.extend( + agents.update_data_source_formulas(user, page, ds_pairs, tool_helpers) + ) + return created, errors + + +def _setup_elements( + user, + page, + elements: list, + el_ref_to_id: dict[str, int], + ds_ref_to_id: dict[str, int], + shared_page_refs: set[str], + tool_helpers, +) -> tuple[list[dict], list[str]]: + """Create elements in order, generate formulas, and handle table actions. + + Mutates *el_ref_to_id* and *shared_page_refs* in place. + Returns ``(created, errors)``. + """ + + created: list[dict] = [] + errors: list[str] = [] + if not elements: + return created, errors + + element_mapping: dict[str, tuple[Any, ElementItemCreate]] = {} + table_action_pairs: list[tuple] = [] + + tool_helpers.update_status( + _("Creating %(count)d elements on %(name)s...") + % {"count": len(elements), "name": page.name} + ) + with transaction.atomic(): + for el_create in elements: + tool_helpers.raise_if_cancelled() + try: + element, el_id, action_pairs = helpers.create_element( + user, + page, + el_create, + el_ref_to_id, + ds_ref_to_id, + shared_page_refs, + None, + ) + el_ref_to_id[el_create.ref] = el_id + element_mapping[el_create.ref] = (element, el_create) + table_action_pairs.extend(action_pairs) + created.append( + {"id": el_id, "ref": el_create.ref, "type": el_create.type} + ) + except Exception as exc: + errors.append(f"element {el_create.ref}: {exc}") + errors.extend( + agents.update_element_formulas( + user, page, elements, element_mapping, tool_helpers + ) + ) + if table_action_pairs: + errors.extend( + agents.update_workflow_action_formulas( + user, page, table_action_pairs, tool_helpers + ) + ) + return created, errors + + +def _setup_actions( + user, + page, + actions: list, + el_ref_to_id: dict[str, int], + ds_ref_to_id: dict[str, int], + integration, + tool_helpers, +) -> tuple[list[dict], list[str]]: + """Create workflow actions and generate their formulas. + + Returns ``(created, errors)``. + """ + + created: list[dict] = [] + errors: list[str] = [] + if not actions: + return created, errors + + action_pairs: list[tuple] = [] + with transaction.atomic(): + for action_create in actions: + tool_helpers.raise_if_cancelled() + tool_helpers.update_status( + _("Creating %(type)s action...") % {"type": action_create.type} + ) + try: + orm_action, action_id = helpers.create_workflow_action( + user, + page, + action_create, + el_ref_to_id, + ds_ref_to_id, + integration, + ) + action_pairs.append((orm_action, action_create)) + created.append( + { + "id": action_id, + "type": action_create.type, + "element": action_create.element, + "event": action_create.event, + } + ) + except Exception as exc: + errors.append( + f"action {action_create.type} on {action_create.element}: {exc}" + ) + errors.extend( + agents.update_workflow_action_formulas(user, page, action_pairs, tool_helpers) + ) + return created, errors + + +# --------------------------------------------------------------------------- +# Composite page setup tool +# --------------------------------------------------------------------------- + + +def setup_page( + ctx: RunContext[AssistantDeps], + page_id: Annotated[int, Field(description="The page ID.")], + data_sources: Annotated[ + list[DataSourceCreate] | None, + Field(default=None, description="Data sources to create."), + ] = None, + elements: Annotated[ + list[ElementItemCreate] | None, + Field(default=None, description="Elements to create (all types)."), + ] = None, + actions: Annotated[ + list[ActionCreate] | None, + Field(default=None, description="Workflow actions to create."), + ] = None, + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ] = "", +) -> dict[str, Any]: + """\ + Set up a complete page: data sources, elements, and actions in one call. + + WHEN to use: Building a complete page with data, UI elements, and interactions. + WHAT it does: Creates data sources first, then elements (in order), then actions. Handles ref resolution across all three phases. + RETURNS: Created items with ref-to-ID mappings and any errors. Partial success is possible — some items may be created even when others fail. Check the ``errors`` key. + + ## Deduplication + Data sources are deduplicated by name (case-insensitive) and by structural match (same type and table). Existing data sources are reused and their IDs mapped to the provided refs. + + ## Execution Order + 1. Data sources (list_rows, get_row) — so elements can reference them. + 2. Elements (in order — parents before children): heading, text, button, link, image, column, form_container, simple_container, input_text, choice, checkbox, datetime_picker, record_selector, table, repeat. + 3. Actions (click, submit) — attached to elements via ref. + + ## Element Fields by Type + - heading: value (text), level (1-5) + - text: value (text), format ("plain"/"markdown") + - button/link: value (label) + - image: image_url, alt_text + - column: column_count + - form_container: submit_button_label + - input_text: label, placeholder, default_value, required, validation_type, is_multiline + - choice: label, choice_options, multiple + - checkbox: label, default_value + - table: data_source (ref), fields [{name, type ("text"/"button"), value}] + - repeat: data_source (ref), orientation + + ## Refs + - data_source refs: referenced by elements (data_source field) and actions (data_source field) + - element refs: referenced by child elements (parent_element field) and actions (element field) + - Use string refs for items created in this call, int IDs for pre-existing items. + + ## Dynamic Values + Use "$formula: " — describe the data you want using references or ids when possible. + Examples: "$formula: the Name field from the projects data source", "$formula: the Email form input". + + ## Shared Elements (header, footer) — CRITICAL + Headers and footers are shared across ALL pages. Any element placed inside + a header/footer (as a child) is also shared and appears on every page. + - ONLY put site-wide navigation in headers/footers: menus, logo, links to fixed pages. + - NEVER put page-specific content as children of headers/footers: page titles, + data-bound text, forms, "back" buttons, or content that varies per page. + - Page-specific content belongs directly on the page root, NOT inside shared containers. + - If a header already exists (page_name="[shared]" in list_elements), do NOT + add page-specific children to it. + """ + + user = ctx.deps.user + tool_helpers = ctx.deps.tool_helpers + + page = helpers.get_page(user, page_id) + shared_page = PageHandler().get_shared_page(page.builder) + integration = helpers.get_local_baserow_integration(user, page.builder) + + ds_ref_to_id: dict[str, int] = _get_data_source_refs(tool_helpers, page_id) + el_ref_to_id: dict[str, int] = _get_element_refs(tool_helpers, page_id) + shared_page_refs: set[str] = set( + _get_element_refs(tool_helpers, shared_page.id).keys() + ) + + result: dict[str, Any] = {} + all_errors: list[str] = [] + + # Phase 1: Data sources + created_ds, ds_errors = _setup_data_sources( + user, page, data_sources or [], ds_ref_to_id, integration, tool_helpers + ) + all_errors.extend(ds_errors) + _track_data_source_refs(tool_helpers, page_id, ds_ref_to_id) + if created_ds: + result["created_data_sources"] = created_ds + + # Phase 2: Elements + created_el, el_errors = _setup_elements( + user, + page, + elements or [], + el_ref_to_id, + ds_ref_to_id, + shared_page_refs, + tool_helpers, + ) + all_errors.extend(el_errors) + _track_element_refs(tool_helpers, page_id, el_ref_to_id) + _track_element_refs( + tool_helpers, + shared_page.id, + {r: 0 for r in el_ref_to_id if r in shared_page_refs}, + ) + if created_el: + result["created_elements"] = created_el + + # Phase 3: Actions + created_actions, action_errors = _setup_actions( + user, + page, + actions or [], + el_ref_to_id, + ds_ref_to_id, + integration, + tool_helpers, + ) + all_errors.extend(action_errors) + if created_actions: + result["created_actions"] = created_actions + + if all_errors: + result["errors"] = all_errors + + # Navigate to the page + tool_helpers.navigate_to( + BuilderPageNavigationType( + type="builder-page", + application_id=page.builder_id, + page_id=page_id, + page_name=page.name, + ) + ) + + return result + + +# --------------------------------------------------------------------------- +# Tool: setup_user_source +# --------------------------------------------------------------------------- + + +def setup_user_source( + ctx: RunContext[AssistantDeps], + application_id: Annotated[int, Field(description="The builder application ID.")], + setup: Annotated[UserSourceSetup, Field(description="User source configuration.")], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Set up a user source so the application can have logged-in users with roles. + + WHEN to use: User wants login, authentication, role-based access, or user accounts in their app. + WHAT it does: Creates a users table (or uses an existing one) and configures a Local Baserow user source with password authentication. + RETURNS: User source ID, table ID, available roles. + DO NOT USE when: A user source already exists for this application. + HOW: If user mentions a specific table, use table_id. Otherwise use database_id to create a new users table. + """ + + user = ctx.deps.user + workspace = ctx.deps.workspace + tool_helpers = ctx.deps.tool_helpers + + try: + builder = helpers.get_builder(user, workspace, application_id) + except Exception as exc: + return {"error": f"Could not find application: {exc}"} + + tool_helpers.update_status(_("Setting up user source...")) + integration = helpers.get_local_baserow_integration(user, builder) + + try: + with transaction.atomic(): + if setup.table_id: + tool_helpers.update_status(_("Validating existing table...")) + table, field_map = helpers.resolve_existing_table( + user, workspace, setup.table_id + ) + else: + tool_helpers.update_status(_("Creating users table...")) + table, field_map = helpers.create_users_table( + user, setup.database_id, workspace, setup.get_roles() + ) + + tool_helpers.update_status(_("Creating user source...")) + user_source = helpers.create_user_source( + user, builder, setup.name, table, field_map, integration + ) + except Exception as exc: + return {"error": str(exc)} + + # Create login page if not already set + login_page_id = builder.login_page_id + if not login_page_id: + tool_helpers.update_status(_("Creating login page...")) + login_page = helpers.create_login_page(user, builder, user_source.id) + login_page_id = login_page.id + + from baserow.core.user_sources.handler import UserSourceHandler + + roles = UserSourceHandler().get_all_roles_for_application(builder) + + result: dict[str, Any] = { + "user_source_id": user_source.id, + "table_id": table.id, + "roles": roles, + "login_page_id": login_page_id, + } + if "hint" in field_map: + result["hint"] = field_map["hint"] + return result + + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- + +TOOL_FUNCTIONS = [ + list_pages, + create_pages, + update_page, + list_data_sources, + create_data_sources, + update_data_source, + list_elements, + create_display_elements, + create_layout_elements, + create_form_elements, + create_collection_elements, + update_element, + update_element_style, + move_elements, + list_actions, + create_actions, + add_action_field_mapping, + setup_page, + setup_user_source, +] +builder_toolset = FunctionToolset(TOOL_FUNCTIONS, max_retries=3) + +ROUTING_RULES = """\ +- New page with content: call create_pages first, then setup_page for the NEW page. If elements don't fit the current page context, ask which page to target. +- switch_mode: switch domain if task needs tools not in the current mode. +- Use setup_page when creating all content for a page at once. Use individual tools (create_data_sources, create_*_elements, create_actions) when adding to or modifying a page that already has content. +- Button/form actions (click, submit) → create_actions. Do NOT switch to database mode to use load_row_tools for this — that is for direct database CRUD, not builder page behavior. +- switch_mode when the task needs tools from another domain. Examples: + - Filtering: switch_mode("database") → create_views + create_view_filters → switch_mode("application") → create_data_sources with view_id. + - New tables for an app: switch_mode("database") → create_tables → switch_mode("application") → create_pages → setup_page. +- User authentication: if the app needs login/roles, call setup_user_source before creating pages with visibility="logged-in". +- Completeness checks before finishing: + - Every page that displays data needs at least one data source. + - Table/repeat elements must specify their columns/fields. + - Forms need input elements + a submit action (create_row or update_row). + - Buttons and links need a click action (open_page, notification, etc.).""" diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/__init__.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/__init__.py new file mode 100644 index 0000000000..90aa659a6b --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/__init__.py @@ -0,0 +1,73 @@ +from .data_source import ( + DataSourceCreate, + DataSourceItem, + DataSourceSort, + DataSourceUpdate, +) +from .element import ( + ButtonStyleOverride, + ChoiceOption, + CollectionElementCreate, + DisplayElementCreate, + ElementItem, + ElementItemCreate, + ElementMove, + ElementStyleUpdate, + ElementUpdate, + FormElementCreate, + ImageStyleOverride, + InputStyleOverride, + ItemsPerRow, + LayoutElementCreate, + LinkStyleOverride, + MenuItemCreate, + ParameterMapping, + TableFieldConfig, + TableStyleOverride, + TypographyStyleOverride, +) +from .page import PageCreate, PageItem, PagePathParam, PageQueryParam, PageUpdate +from .workflow_action import ( + ActionCreate, + ActionItem, + ActionType, + FieldMappingItem, + FieldValueMapping, +) + +__all__ = [ + "ActionCreate", + "ActionItem", + "ActionType", + "ButtonStyleOverride", + "ChoiceOption", + "CollectionElementCreate", + "DataSourceCreate", + "DataSourceItem", + "DataSourceSort", + "DataSourceUpdate", + "DisplayElementCreate", + "ElementItem", + "ElementItemCreate", + "ElementMove", + "ElementStyleUpdate", + "ElementUpdate", + "FieldMappingItem", + "FieldValueMapping", + "FormElementCreate", + "ImageStyleOverride", + "InputStyleOverride", + "ItemsPerRow", + "LayoutElementCreate", + "LinkStyleOverride", + "MenuItemCreate", + "PageCreate", + "PageItem", + "PagePathParam", + "PageQueryParam", + "PageUpdate", + "ParameterMapping", + "TableFieldConfig", + "TableStyleOverride", + "TypographyStyleOverride", +] diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/data_source.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/data_source.py new file mode 100644 index 0000000000..ca97d2f538 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/data_source.py @@ -0,0 +1,375 @@ +""" +Builder data source type models. + +Defines ``DataSourceCreate`` (flat) for creating data sources and +``DataSourceItem`` for reading them back. +""" + +from typing import TYPE_CHECKING, Any, Callable, Literal + +from pydantic import Field, PrivateAttr, model_serializer, model_validator + +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_ADVANCED, + BaserowFormulaObject, +) +from baserow_enterprise.assistant.tools.shared.formula_utils import ( + formula_desc, + literal_or_placeholder, + needs_formula, +) +from baserow_enterprise.assistant.types import BaseModel + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + + from baserow_enterprise.assistant.tools.builder.agents import BuilderFormulaContext + +# --------------------------------------------------------------------------- +# Data source sort +# --------------------------------------------------------------------------- + +DataSourceType = Literal["list_rows", "get_row"] + + +class DataSourceSort(BaseModel): + """Sort configuration for data source.""" + + field_id: int = Field(..., description="Field ID to sort by.") + direction: Literal["ASC", "DESC"] = Field(default="ASC") + + +# --------------------------------------------------------------------------- +# Required fields per type +# --------------------------------------------------------------------------- + +_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { + "list_rows": ("table_id",), + "get_row": ("table_id", "row_id"), +} + +# --------------------------------------------------------------------------- +# Service type mapping +# --------------------------------------------------------------------------- + +_SERVICE_TYPE: dict[str, str] = { + "list_rows": "local_baserow_list_rows", + "get_row": "local_baserow_get_row", +} + +# Structural match dispatch: type -> (new, existing) -> bool +# Used to detect duplicate data sources regardless of name. +_STRUCTURAL_MATCH: dict[str, Callable] = { + "list_rows": lambda new, ex: new.table_id == ex.table_id, + "get_row": lambda new, ex: False, # row_id varies, can't dedup +} + + +# --------------------------------------------------------------------------- +# DataSourceCreate (flat) +# --------------------------------------------------------------------------- + + +class DataSourceCreate(BaseModel): + """ + Flat model for creating a data source: list_rows or get_row. + + Type-specific fields are optional — a ``@model_validator`` enforces + the correct required fields per type. + """ + + ref: str = Field(..., description="Reference ID for this data source.") + name: str = Field(..., description="Human-readable name.") + type: DataSourceType = Field(..., description="'list_rows' or 'get_row'.") + table_id: int = Field(..., description="ID of the table to fetch from.") + + # get_row only + row_id: str | None = Field( + default=None, + description=("(get_row) Row ID. Supports $formula: prefix for dynamic values."), + ) + + # list_rows only + search_query: str | None = Field( + default=None, + description=( + "(list_rows) Search query. Supports $formula: prefix for dynamic values." + ), + ) + sortings: list[DataSourceSort] | None = Field( + default=None, + description="(list_rows) Sort configuration.", + ) + view_id: int | None = Field( + default=None, + description=( + "(list_rows) ID of a database table view whose filters and sortings " + "will be applied to this data source. Use create_views and " + "create_view_filters to set up the view first." + ), + ) + + @model_validator(mode="after") + def _check_required(self): + for field_name in _REQUIRED_FIELDS.get(self.type, ()): + if getattr(self, field_name) is None: + raise ValueError(f"'{field_name}' is required for type '{self.type}'.") + return self + + # -- ORM helpers -------------------------------------------------------- + + def get_service_type(self) -> str: + """Return the service type string for this data source.""" + return _SERVICE_TYPE[self.type] + + def matches_existing(self, existing: "DataSourceItem") -> bool: + """Check if this create request would produce a duplicate of *existing*. + + Delegates to a per-type matcher in ``_STRUCTURAL_MATCH``. + """ + + if self.type != existing.type: + return False + matcher = _STRUCTURAL_MATCH.get(self.type) + return matcher(self, existing) if matcher else False + + def to_service_kwargs(self, user: "AbstractUser", workspace: Any) -> dict: + """Build kwargs for ``DataSourceService.create_data_source()``.""" + + from baserow_enterprise.assistant.tools.builder.helpers import ToolInputError + from baserow_enterprise.assistant.tools.database.helpers import filter_tables + + table = filter_tables(user, workspace).filter(id=self.table_id).first() + if table is None: + raise ToolInputError(f"Table with id {self.table_id} not found.") + kwargs: dict[str, Any] = {"table": table} + + if self.type == "get_row" and self.row_id is not None: + kwargs["row_id"] = BaserowFormulaObject.create( + literal_or_placeholder(self.row_id), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + + if self.type == "list_rows" and self.search_query: + kwargs["search_query"] = BaserowFormulaObject.create( + literal_or_placeholder(self.search_query), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + + if self.view_id is not None: + from baserow_enterprise.assistant.tools.database.helpers import get_view + + kwargs["view"] = get_view(user, workspace, self.view_id) + + return kwargs + + def get_sortings(self) -> list[dict]: + """Return sortings in ORM format.""" + if not self.sortings: + return [] + return [ + {"field_id": s.field_id, "order_by": s.direction} for s in self.sortings + ] + + # -- Formula helpers ---------------------------------------------------- + + def get_formulas_to_create( + self, + orm_data_source: Any, + context: "BuilderFormulaContext", + ) -> dict[str, str]: + """Return ``{field_path: description}`` for LLM formula generation.""" + + formulas: dict[str, str] = {} + + if self.type == "get_row" and self.row_id and needs_formula(self.row_id): + formulas["row_id"] = formula_desc(self.row_id) + + if ( + self.type == "list_rows" + and self.search_query + and needs_formula(self.search_query) + ): + formulas["search_query"] = formula_desc(self.search_query) + + return formulas + + def update_with_formulas( + self, + user: "AbstractUser", + orm_data_source: Any, + formulas: dict[str, str], + ) -> None: + """Apply LLM-generated formulas to this data source.""" + + if not formulas: + return + + from baserow.contrib.builder.data_sources.handler import DataSourceHandler + from baserow.contrib.builder.data_sources.service import DataSourceService + from baserow.core.services.registries import service_type_registry + + service_kwargs: dict[str, Any] = {} + + if "row_id" in formulas: + service_kwargs["row_id"] = BaserowFormulaObject.create( + formulas["row_id"], mode=BASEROW_FORMULA_MODE_ADVANCED + ) + + if "search_query" in formulas: + service_kwargs["search_query"] = BaserowFormulaObject.create( + formulas["search_query"], mode=BASEROW_FORMULA_MODE_ADVANCED + ) + + if service_kwargs: + ds_for_update = DataSourceHandler().get_data_source_for_update( + orm_data_source.id + ) + service_type = service_type_registry.get_by_model( + ds_for_update.service.specific + ) + DataSourceService().update_data_source( + user, ds_for_update, service_type=service_type, **service_kwargs + ) + + +# --------------------------------------------------------------------------- +# DataSourceUpdate +# --------------------------------------------------------------------------- + + +class DataSourceUpdate(BaseModel): + """ + Update an existing data source's properties. + + All fields are optional. Only non-None fields are sent to the service layer. + """ + + data_source_id: int = Field(..., description="ID of the data source to update.") + name: str | None = Field(default=None, description="New name.") + table_id: int | None = Field( + default=None, description="New table ID to fetch from." + ) + row_id: str | None = Field( + default=None, + description="(get_row) Row ID. Supports $formula: prefix.", + ) + search_query: str | None = Field( + default=None, + description="(list_rows) Search query. Supports $formula: prefix.", + ) + view_id: int | None = Field( + default=None, + description=( + "ID of a database table view whose filters and sortings will be applied." + ), + ) + + def to_update_kwargs(self, user: "AbstractUser", workspace: Any) -> dict: + """Return kwargs for ``DataSourceService.update_data_source()``.""" + + kwargs: dict[str, Any] = {} + + if self.name is not None: + kwargs["name"] = self.name + + if self.table_id is not None: + from baserow_enterprise.assistant.tools.builder.helpers import ( + ToolInputError, + ) + from baserow_enterprise.assistant.tools.database.helpers import ( + filter_tables, + ) + + table = filter_tables(user, workspace).filter(id=self.table_id).first() + if table is None: + raise ToolInputError(f"Table with id {self.table_id} not found.") + kwargs["table"] = table + + if self.row_id is not None: + kwargs["row_id"] = BaserowFormulaObject.create( + literal_or_placeholder(self.row_id), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + + if self.search_query is not None: + kwargs["search_query"] = BaserowFormulaObject.create( + literal_or_placeholder(self.search_query), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + + if self.view_id is not None: + from baserow_enterprise.assistant.tools.database.helpers import get_view + + kwargs["view"] = get_view(user, workspace, self.view_id) + + return kwargs + + def get_formulas_to_update( + self, + orm_data_source: Any, + context: "BuilderFormulaContext", + ) -> dict[str, str]: + """Return ``{field_path: description}`` for LLM formula generation.""" + + formulas: dict[str, str] = {} + if self.row_id and needs_formula(self.row_id): + formulas["row_id"] = formula_desc(self.row_id) + if self.search_query and needs_formula(self.search_query): + formulas["search_query"] = formula_desc(self.search_query) + return formulas + + def get_updated_field_names(self) -> list[str]: + """Return names of fields that were explicitly set (non-None).""" + + skip = {"data_source_id"} + return [ + name + for name in self.__class__.model_fields + if name not in skip and getattr(self, name) is not None + ] + + +# --------------------------------------------------------------------------- +# DataSourceItem (for listing) +# --------------------------------------------------------------------------- + + +class DataSourceItem(BaseModel): + """Existing data source with ID.""" + + id: int + name: str + type: str + table_id: int | None = None + + _table_name: str | None = PrivateAttr(default=None) + + @model_serializer(mode="wrap") + def _serialize(self, handler): + data = handler(self) + if self._table_name is not None: + data["table_name"] = self._table_name + return data + + @classmethod + def from_orm(cls, data_source) -> "DataSourceItem": + """Create DataSourceItem from ORM DataSource instance.""" + + table_id = None + table_name = None + if data_source.service: + service = data_source.service.specific + if hasattr(service, "table_id"): + table_id = service.table_id + if hasattr(service, "table") and service.table: + table_name = service.table.name + + item = cls( + id=data_source.id, + name=data_source.name, + type=data_source.service.get_type().type if data_source.service else "", + table_id=table_id, + ) + item._table_name = table_name + return item diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/element.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/element.py new file mode 100644 index 0000000000..afaf9671b5 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/element.py @@ -0,0 +1,2269 @@ +""" +Builder element type models. + +Defines ``ElementItemCreate`` (flat) for creating UI elements and +``ElementItem`` for reading them back. Uses dispatch tables for +per-type ORM conversion and formula handling. + +## Dispatch Tables (add entries when adding a new element type) + +When adding support for a new element type, update these tables: + +- ``_TO_ORM``: Convert ``ElementItemCreate`` → ORM kwargs for creation. +- ``_POST_CREATE``: Hook called after ORM creation (e.g. for child objects). +- ``_GET_FORMULAS``: Return ``{field: description}`` for LLM formula generation. +- ``_UPDATE_FORMULAS``: Apply generated formulas to the ORM element. +- ``_TO_ORM_UPDATE``: Convert ``ElementUpdate`` → ORM kwargs for updates. +- ``_GET_UPDATE_FORMULAS``: Return formulas needed for element updates. + +Not all tables need entries — only add to those relevant for the new type. +""" + +import uuid +from typing import TYPE_CHECKING, Any, Literal + +from pydantic import Field, model_validator + +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_ADVANCED, + BaserowFormulaObject, +) +from baserow_enterprise.assistant.tools.shared.formula_utils import ( + formula_desc, + literal_or_placeholder, + needs_formula, + wrap_static_string, +) +from baserow_enterprise.assistant.types import BaseModel + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + + from baserow.contrib.builder.elements.models import Element + from baserow.contrib.builder.pages.models import Page + from baserow_enterprise.assistant.tools.builder.agents import BuilderFormulaContext + +# --------------------------------------------------------------------------- +# Element type literal (excluding iframe) +# --------------------------------------------------------------------------- + +ElementType = Literal[ + "heading", + "text", + "button", + "link", + "image", + "column", + "form_container", + "simple_container", + "input_text", + "choice", + "checkbox", + "datetime_picker", + "record_selector", + "table", + "repeat", + "header", + "footer", + "menu", + "auth_form", +] + +CONTAINER_ELEMENT_TYPES = { + "column", + "form_container", + "simple_container", + "repeat", + "header", + "footer", +} + +# Elements that live on the shared page (visible across all pages). +_SHARED_PAGE_TYPES = {"header", "footer"} + + +# --------------------------------------------------------------------------- +# Sub-models +# --------------------------------------------------------------------------- + + +class ParameterMapping(BaseModel): + """Key-value parameter mapping for page/query parameters on links.""" + + name: str = Field(..., description="Parameter name.") + value: str = Field(..., description="Parameter value formula.") + + +class ChoiceOption(BaseModel): + """Option for choice element.""" + + name: str + value: str + + +class ItemsPerRow(BaseModel): + """Responsive items-per-row for repeat elements.""" + + desktop: int = Field(default=3) + tablet: int = Field(default=2) + smartphone: int = Field(default=1) + + +class MenuItemCreate(BaseModel): + """A menu item linking to an internal page.""" + + name: str = Field(..., description="Display text.") + page_id: int = Field(..., description="Target page ID.") + + +class TableFieldConfig(BaseModel): + """ + Column configuration for table elements. + + ``type`` is ``"text"`` (default) or ``"button"``. + """ + + name: str = Field(..., description="Column header name.") + type: Literal["text", "button", "link", "tags"] = Field( + default="text", description="Column type." + ) + + # text columns + value: str | None = Field( + default=None, + description="(text) Cell value formula. Supports $formula: prefix.", + ) + + # button columns + label: str | None = Field(default=None, description="(button) Button label.") + + +# --------------------------------------------------------------------------- +# ORM dispatch: element_type -> kwargs builder +# --------------------------------------------------------------------------- + + +def _heading_orm(el: "ElementItemCreate", user, page) -> dict: + return { + "value": BaserowFormulaObject.create( + literal_or_placeholder(el.value) + if needs_formula(el.value) + else wrap_static_string(el.value or ""), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + "level": el.level or 1, + } + + +def _text_orm(el: "ElementItemCreate", user, page) -> dict: + return { + "value": BaserowFormulaObject.create( + literal_or_placeholder(el.value) + if needs_formula(el.value) + else wrap_static_string(el.value or ""), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + "format": el.format or "plain", + } + + +def _button_orm(el: "ElementItemCreate", user, page) -> dict: + text = el.value or el.label or "" + return { + "value": BaserowFormulaObject.create( + literal_or_placeholder(text) + if needs_formula(text) + else wrap_static_string(text), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + } + + +def _link_orm(el: "ElementItemCreate", user, page) -> dict: + kwargs: dict[str, Any] = { + "value": BaserowFormulaObject.create( + literal_or_placeholder(el.value) + if needs_formula(el.value) + else wrap_static_string(el.value or ""), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + "variant": el.link_variant or "link", + "navigation_type": el.navigation_type or "page", + "target": el.link_target or "self", + } + + nav = el.navigation_type or "page" + if nav == "page" and el.navigate_to_page_id: + kwargs["navigate_to_page_id"] = el.navigate_to_page_id + kwargs["page_parameters"] = [ + { + "name": p.name, + "value": BaserowFormulaObject.create( + p.value, mode=BASEROW_FORMULA_MODE_ADVANCED + ), + } + for p in (el.link_page_parameters or []) + ] + elif nav == "custom" and el.navigate_to_url: + kwargs["navigate_to_url"] = BaserowFormulaObject.create( + literal_or_placeholder(el.navigate_to_url) + if needs_formula(el.navigate_to_url) + else wrap_static_string(el.navigate_to_url), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + + return kwargs + + +def _image_orm(el: "ElementItemCreate", user, page) -> dict: + image_url = el.image_url or "" + alt_text = el.alt_text or "" + return { + "image_source_type": el.image_source_type or "url", + "image_url": BaserowFormulaObject.create( + literal_or_placeholder(image_url) + if needs_formula(image_url) + else wrap_static_string(image_url) + if image_url + else "''", + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + "alt_text": BaserowFormulaObject.create( + literal_or_placeholder(alt_text) + if needs_formula(alt_text) + else wrap_static_string(alt_text) + if alt_text + else "''", + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + } + + +def _column_orm(el: "ElementItemCreate", user, page) -> dict: + return { + "column_amount": el.column_amount or 2, + "column_gap": el.column_gap or 20, + "alignment": el.column_alignment or "top", + } + + +def _form_container_orm(el: "ElementItemCreate", user, page) -> dict: + return { + "submit_button_label": BaserowFormulaObject.create( + f"'{el.submit_button_label or 'Submit'}'", + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + "reset_initial_values_post_submission": el.reset_initial_values_post_submission + or False, + } + + +def _input_text_orm(el: "ElementItemCreate", user, page) -> dict: + default_value = el.default_value or "" + return { + "label": BaserowFormulaObject.create( + f"'{el.label or ''}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ), + "placeholder": BaserowFormulaObject.create( + f"'{el.placeholder or ''}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ), + "default_value": BaserowFormulaObject.create( + literal_or_placeholder(default_value) + if needs_formula(default_value) + else (wrap_static_string(default_value) if default_value else "''"), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + "required": el.required or False, + "validation_type": el.validation_type or "any", + "is_multiline": el.is_multiline or False, + "rows": el.rows or 3, + } + + +def _choice_orm(el: "ElementItemCreate", user, page) -> dict: + default_value = el.default_value or "" + return { + "label": BaserowFormulaObject.create( + f"'{el.label or ''}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ), + "placeholder": BaserowFormulaObject.create( + f"'{el.placeholder or ''}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ), + "default_value": BaserowFormulaObject.create( + literal_or_placeholder(default_value) + if needs_formula(default_value) + else (wrap_static_string(default_value) if default_value else "''"), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + "required": el.required or False, + "multiple": el.multiple or False, + "show_as_dropdown": el.show_as_dropdown + if el.show_as_dropdown is not None + else True, + } + + +def _checkbox_orm(el: "ElementItemCreate", user, page) -> dict: + default_value = el.default_value or "false" + return { + "label": BaserowFormulaObject.create( + f"'{el.label or ''}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ), + "default_value": BaserowFormulaObject.create( + literal_or_placeholder(default_value) + if needs_formula(default_value) + else default_value, + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + "required": el.required or False, + } + + +def _datetime_picker_orm(el: "ElementItemCreate", user, page) -> dict: + return { + "label": BaserowFormulaObject.create( + f"'{el.label or ''}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ), + "required": el.required or False, + "include_time": el.include_time or False, + "date_format": el.date_format or "EU", + } + + +def _record_selector_orm(el: "ElementItemCreate", user, page) -> dict: + return { + "label": BaserowFormulaObject.create( + f"'{el.label or ''}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ), + "data_source_id": el.data_source, + "required": el.required or False, + "multiple": el.multiple or False, + "placeholder": BaserowFormulaObject.create( + f"'{el.placeholder or ''}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ), + } + + +def _table_orm(el: "ElementItemCreate", user, page) -> dict: + kwargs: dict[str, Any] = { + "data_source_id": el.data_source, + "items_per_page": el.items_per_page or 20, + "button_load_more_label": BaserowFormulaObject.create( + f"'{el.button_load_more_label or 'Load more'}'", + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + } + + if el.fields: + kwargs["fields"] = _convert_table_fields(el) + elif el.data_source: + # Auto-generate default fields from data source + from baserow.contrib.builder.data_sources.service import DataSourceService + + data_source = next( + iter( + DataSourceService() + .get_data_sources(user, page, with_shared=True) + .filter(id=el.data_source) + ), + None, + ) + if data_source and hasattr(data_source.service, "table_id"): + service = data_source.service + kwargs["fields"] = service.get_type().get_default_collection_fields(service) + + return kwargs + + +def _repeat_orm(el: "ElementItemCreate", user, page) -> dict: + items_per_row = ( + el.repeat_items_per_row.model_dump() + if el.repeat_items_per_row + else ItemsPerRow().model_dump() + ) + return { + "data_source_id": el.data_source, + "orientation": el.orientation or "vertical", + "items_per_page": el.items_per_page or 20, + "items_per_row": items_per_row, + } + + +def _header_orm(el: "ElementItemCreate", user, page) -> dict: + return {"share_type": el.share_type or "all"} + + +def _footer_orm(el: "ElementItemCreate", user, page) -> dict: + return {"share_type": el.share_type or "all"} + + +def _menu_orm(el: "ElementItemCreate", user, page) -> dict: + kwargs: dict[str, Any] = { + "orientation": el.menu_orientation or "horizontal", + "alignment": el.menu_alignment or "left", + } + if el.menu_items: + kwargs["menu_items"] = [ + { + "uid": str(uuid.uuid4()), + "type": "link", + "variant": "link", + "name": item.name, + "navigation_type": "page", + "navigate_to_page_id": item.page_id, + "target": "self", + } + for item in el.menu_items + ] + return kwargs + + +_TO_ORM: dict[str, Any] = { + "heading": _heading_orm, + "text": _text_orm, + "button": _button_orm, + "link": _link_orm, + "image": _image_orm, + "column": _column_orm, + "form_container": _form_container_orm, + "simple_container": lambda el, u, p: {}, + "input_text": _input_text_orm, + "choice": _choice_orm, + "checkbox": _checkbox_orm, + "datetime_picker": _datetime_picker_orm, + "record_selector": _record_selector_orm, + "table": _table_orm, + "repeat": _repeat_orm, + "header": _header_orm, + "footer": _footer_orm, + "menu": _menu_orm, + "auth_form": lambda el, u, p: { + k: v + for k, v in { + "user_source_id": el.user_source_id, + "login_button_label": el.login_button_label or "", + }.items() + if v is not None + }, +} + + +# --------------------------------------------------------------------------- +# Post-create dispatch +# --------------------------------------------------------------------------- + + +def _choice_post_create(el: "ElementItemCreate", user, orm_element, page) -> None: + """Create choice options after the element is created.""" + if el.choice_options: + from baserow.contrib.builder.elements.models import ChoiceElementOption + + ChoiceElementOption.objects.bulk_create( + [ + ChoiceElementOption(choice=orm_element, name=o.name, value=o.value) + for o in el.choice_options + ] + ) + + +def _header_footer_post_create( + el: "ElementItemCreate", user, orm_element, page +) -> None: + """Set page associations and auto-create a child menu for header/footer elements.""" + + if el.page_ids: + orm_element.pages.set(el.page_ids) + + # If menu_items were provided on the header/footer itself, create a child + # menu element — headers are containers, not menus. + if el.menu_items: + from baserow.contrib.builder.elements.registries import element_type_registry + from baserow.contrib.builder.elements.service import ElementService + + menu_items_orm = [ + { + "uid": str(uuid.uuid4()), + "type": "link", + "variant": "link", + "name": item.name, + "navigation_type": "page", + "navigate_to_page_id": item.page_id, + "target": "self", + } + for item in el.menu_items + ] + menu_type = element_type_registry.get("menu") + ElementService().create_element( + user, + menu_type, + page, + parent_element_id=orm_element.id, + menu_items=menu_items_orm, + ) + + +def _table_post_create(el: "ElementItemCreate", user, orm_element, page) -> list: + """Create button-column workflow actions and enable filter/sort/search. + + Returns a list of ``(orm_action, action_create)`` pairs for any + button-column actions that were created (empty list if none). + """ + + if not el.fields: + return [] + + from baserow.contrib.builder.elements.models import CollectionElementPropertyOptions + from baserow_enterprise.assistant.tools.builder.helpers import ( + create_table_button_actions, + get_local_baserow_integration, + ) + + integration = get_local_baserow_integration(user, page.builder) + action_pairs = create_table_button_actions(user, page, orm_element, el, integration) + + # Auto-enable filter/sort/search for text columns referencing real fields. + table_fields = _resolve_table_fields(el.data_source) + property_options = [] + for field_cfg in el.fields: + if field_cfg.type != "text": + continue + match = table_fields.get(field_cfg.name.lower()) + if match: + field_id, _ = match + property_options.append( + CollectionElementPropertyOptions( + element=orm_element, + schema_property=f"field_{field_id}", + filterable=True, + sortable=True, + searchable=True, + ) + ) + if property_options: + CollectionElementPropertyOptions.objects.bulk_create( + property_options, ignore_conflicts=True + ) + + return action_pairs + + +_POST_CREATE: dict[str, Any] = { + "choice": _choice_post_create, + "header": _header_footer_post_create, + "footer": _header_footer_post_create, + "table": _table_post_create, +} + + +# --------------------------------------------------------------------------- +# Formula dispatch: get_formulas_to_create +# --------------------------------------------------------------------------- + + +def _value_formula(el: "ElementItemCreate", orm_element, context) -> dict[str, str]: + """Get formulas for elements with a ``value`` field.""" + if el.value and needs_formula(el.value): + return {"value": formula_desc(el.value)} + return {} + + +def _link_formulas(el: "ElementItemCreate", orm_element, context) -> dict[str, str]: + """Get formulas for link elements.""" + formulas: dict[str, str] = {} + if el.value and needs_formula(el.value): + formulas["value"] = formula_desc(el.value) + if el.navigate_to_url and needs_formula(el.navigate_to_url): + formulas["navigate_to_url"] = formula_desc(el.navigate_to_url) + return formulas + + +def _image_formulas(el: "ElementItemCreate", orm_element, context) -> dict[str, str]: + """Get formulas for image elements.""" + formulas: dict[str, str] = {} + if el.image_url and needs_formula(el.image_url): + formulas["image_url"] = formula_desc(el.image_url) + if el.alt_text and needs_formula(el.alt_text): + formulas["alt_text"] = formula_desc(el.alt_text) + return formulas + + +def _default_value_formula( + el: "ElementItemCreate", orm_element, context +) -> dict[str, str]: + """Get formulas for form elements with a ``default_value`` field.""" + if el.default_value and needs_formula(el.default_value): + return {"default_value": formula_desc(el.default_value)} + return {} + + +def _table_formulas(el: "ElementItemCreate", orm_element, context) -> dict[str, str]: + """Get formulas for table collection fields.""" + if not el.fields: + return {} + + formulas: dict[str, str] = {} + collection_fields = list(orm_element.fields.order_by("order")) + + for i, field_cfg in enumerate(el.fields): + existing_config = ( + collection_fields[i].config if i < len(collection_fields) else None + ) + + if field_cfg.type == "text": + value = field_cfg.value or "" + existing = "" + if existing_config: + existing = existing_config.get("value", {}).get("formula", "") + + if value and needs_formula(value): + if not existing or existing == "''": + formulas[f"fields.{i}.value"] = formula_desc(value) + elif not value and el.data_source: + if not existing or existing == "''": + formulas[f"fields.{i}.value"] = ( + f"the {field_cfg.name} value from the current record" + ) + + elif field_cfg.type == "button" and field_cfg.label: + if needs_formula(field_cfg.label): + formulas[f"fields.{i}.label"] = formula_desc(field_cfg.label) + + return formulas + + +_GET_FORMULAS: dict[str, Any] = { + "heading": _value_formula, + "text": _value_formula, + "button": _value_formula, + "link": _link_formulas, + "image": _image_formulas, + "input_text": _default_value_formula, + "choice": _default_value_formula, + "checkbox": _default_value_formula, + "datetime_picker": _default_value_formula, + "table": _table_formulas, +} + + +# --------------------------------------------------------------------------- +# Formula dispatch: update_element_with_formulas +# --------------------------------------------------------------------------- + + +def _update_simple_formulas( + el: "ElementItemCreate", + user: "AbstractUser", + orm_element: "Element", + formulas: dict[str, str], +) -> None: + """Default formula updater — sets fields directly on the element.""" + from baserow.contrib.builder.elements.service import ElementService + + kwargs = {} + for field_name, formula in formulas.items(): + if "." in field_name: + continue + if hasattr(orm_element, field_name): + kwargs[field_name] = BaserowFormulaObject.create( + formula, mode=BASEROW_FORMULA_MODE_ADVANCED + ) + + if kwargs: + ElementService().update_element(user, orm_element, **kwargs) + + +def _update_table_formulas( + el: "ElementItemCreate", + user: "AbstractUser", + orm_element: "Element", + formulas: dict[str, str], +) -> None: + """Update collection field configs for table elements.""" + if not formulas: + return + + collection_fields = list(orm_element.fields.order_by("order")) + for key, formula in formulas.items(): + parts = key.split(".") + if len(parts) != 3 or parts[0] != "fields": + continue + index = int(parts[1]) + config_key = parts[2] + if 0 <= index < len(collection_fields): + cf = collection_fields[index] + cf.config[config_key] = BaserowFormulaObject.create( + formula, mode=BASEROW_FORMULA_MODE_ADVANCED + ) + cf.save(update_fields=["config"]) + + +_UPDATE_FORMULAS: dict[str, Any] = { + "table": _update_table_formulas, +} + + +# --------------------------------------------------------------------------- +# Table field helpers +# --------------------------------------------------------------------------- + +# Formula path suffixes by field type — mirrors the mapping in +# LocalBaserowListRowsUserServiceType.get_default_collection_fields(). +_FORMULA_PATH_SUFFIX: dict[str, str] = { + "last_modified_by": ".name", + "created_by": ".name", + "single_select": ".value", + "multiple_collaborators": ".*.name", +} +_ARRAY_FIELD_TYPES = {"multiple_select", "link_row"} + + +def _resolve_table_fields(data_source_id: int | None) -> dict[str, tuple[int, str]]: + """ + Look up the data source's table and return a case-insensitive mapping + of field name -> (field_id, field_type_str). + """ + + if not data_source_id: + return {} + try: + from baserow.contrib.builder.data_sources.models import DataSource + + ds = DataSource.objects.select_related("service").get(id=data_source_id) + table = ds.service.specific.table + if table is None: + return {} + return { + f.name.lower(): (f.id, f.get_type().type) + for f in table.field_set.select_related("content_type").all() + } + except Exception: + return {} + + +def _field_formula(field_id: int, field_type: str) -> str: + """Build ``get('current_record.field_')`` formula.""" + + suffix = _FORMULA_PATH_SUFFIX.get(field_type, "") + if not suffix and field_type in _ARRAY_FIELD_TYPES: + suffix = ".*.value" + return f"get('current_record.field_{field_id}{suffix}')" + + +def _convert_table_fields( + el: "ElementItemCreate | None" = None, + *, + data_source_id: int | None = None, + fields: list | None = None, +) -> list[dict]: + """Convert TableFieldConfig list to ORM collection field format. + + Can be called with an ``ElementItemCreate`` (creation path) or with + explicit ``data_source_id`` + ``fields`` (update path). + """ + + if el is not None: + data_source_id = el.data_source # type: ignore[assignment] + fields = el.fields + + table_fields = _resolve_table_fields(data_source_id) + result = [] + for field_cfg in fields or []: + if field_cfg.type == "text": + value = field_cfg.value or "" + if value and not needs_formula(value): + value_formula = wrap_static_string(value) + else: + match = table_fields.get(field_cfg.name.lower()) + value_formula = _field_formula(*match) if match else "''" + result.append( + { + "name": field_cfg.name, + "type": "text", + "config": { + "value": BaserowFormulaObject.create( + value_formula, mode=BASEROW_FORMULA_MODE_ADVANCED + ) + }, + } + ) + elif field_cfg.type == "button": + label = field_cfg.label or field_cfg.name + if needs_formula(label): + label_formula = "''" + else: + label_formula = wrap_static_string(label) + result.append( + { + "name": field_cfg.name, + "type": "button", + "config": { + "label": BaserowFormulaObject.create( + label_formula, mode=BASEROW_FORMULA_MODE_ADVANCED + ) + }, + } + ) + elif field_cfg.type == "link": + result.append({"name": field_cfg.name, "type": "link", "config": {}}) + elif field_cfg.type == "tags": + result.append({"name": field_cfg.name, "type": "tags", "config": {}}) + + return result + + +# --------------------------------------------------------------------------- +# ElementItemCreate (flat) +# --------------------------------------------------------------------------- + + +class ElementItemCreate(BaseModel): + """ + Flat model for creating any builder UI element. + + Type-specific fields are optional. Dispatch tables route ORM conversion, + post-creation hooks, and formula handling based on the ``type`` field. + """ + + ref: str = Field(..., description="Unique reference for this element.") + type: ElementType = Field(..., description="Element type.") + + # -- Common fields ------------------------------------------------------ + + parent_element: int | str | None = Field( + default=None, + description="Parent container: int ID (existing) or string ref (same batch).", + ) + place_in_container: str | None = Field( + default=None, description="Position in parent container (e.g. '0', '1')." + ) + visibility: Literal["all", "logged-in", "not-logged"] = Field(default="all") + role_type: Literal["allow_all", "allow_all_except", "disallow_all_except"] = Field( + default="allow_all", + description="Role access strategy. Only relevant when visibility='logged-in'.", + ) + roles: list[str] = Field( + default_factory=list, + description="Role names for the access strategy.", + ) + + data_source: int | str | None = Field( + default=None, + description="Data source: int ID (existing) or string ref (same batch).", + ) + + # -- Display fields (heading, text, button, link) ----------------------- + + value: str | None = Field( + default=None, + description="Display text (heading, text, button label, link text). Supports $formula: prefix.", + ) + level: int | None = Field(default=None, description="(heading) Level 1-5.") + format: str | None = Field( + default=None, description="(text) 'plain' or 'markdown'." + ) + + # -- Link fields -------------------------------------------------------- + + link_variant: Literal["link", "button"] | None = Field( + default=None, description="(link) Display variant." + ) + navigation_type: Literal["page", "custom"] | None = Field( + default=None, description="(link) Navigation type." + ) + navigate_to_page_id: int | None = Field( + default=None, description="(link, open_page) Target page ID." + ) + navigate_to_url: str | None = Field( + default=None, + description="(link) Custom URL. Supports $formula: prefix.", + ) + link_page_parameters: list[ParameterMapping] | None = Field( + default=None, description="(link) Page parameter mappings." + ) + link_target: Literal["self", "blank"] | None = Field( + default=None, description="(link) Navigation target." + ) + + # -- Image fields ------------------------------------------------------- + + image_source_type: Literal["upload", "url"] | None = Field( + default=None, description="(image) Source type." + ) + image_url: str | None = Field( + default=None, + description="(image) Image URL. Supports $formula: prefix.", + ) + alt_text: str | None = Field( + default=None, + description="(image) Alt text. Supports $formula: prefix.", + ) + + # -- Column fields ------------------------------------------------------ + + column_amount: int | None = Field( + default=None, description="(column) Number of columns (1-6)." + ) + column_gap: int | None = Field( + default=None, description="(column) Gap between columns in px." + ) + column_alignment: Literal["top", "center", "bottom"] | None = Field( + default=None, description="(column) Vertical alignment." + ) + + # -- Form container fields ---------------------------------------------- + + submit_button_label: str | None = Field( + default=None, description="(form_container) Submit button label." + ) + reset_initial_values_post_submission: bool | None = Field( + default=None, description="(form_container) Reset form after submit." + ) + + # -- Form input fields -------------------------------------------------- + + label: str | None = Field( + default=None, + description="(form inputs) Field label.", + ) + placeholder: str | None = Field( + default=None, description="(form inputs) Placeholder text." + ) + default_value: str | None = Field( + default=None, + description="(form inputs) Default value. Supports $formula: prefix.", + ) + required: bool | None = Field( + default=None, description="(form inputs) Required field." + ) + + # input_text specific + validation_type: Literal["any", "email", "integer"] | None = Field( + default=None, description="(input_text) Validation type." + ) + is_multiline: bool | None = Field( + default=None, description="(input_text) Multiline mode." + ) + rows: int | None = Field( + default=None, description="(input_text) Rows for multiline." + ) + + # choice specific + multiple: bool | None = Field( + default=None, description="(choice, record_selector) Allow multiple." + ) + show_as_dropdown: bool | None = Field( + default=None, description="(choice) Show as dropdown." + ) + choice_options: list[ChoiceOption] | None = Field( + default=None, description="(choice) List of options." + ) + + # datetime_picker specific + include_time: bool | None = Field( + default=None, description="(datetime_picker) Include time." + ) + date_format: Literal["EU", "US", "ISO"] | None = Field( + default=None, description="(datetime_picker) Date format." + ) + + # -- Collection fields (table, repeat) ---------------------------------- + + items_per_page: int | None = Field( + default=None, description="(table, repeat) Items per page." + ) + button_load_more_label: str | None = Field( + default=None, description="(table) Load more button label." + ) + fields: list[TableFieldConfig] | None = Field( + default=None, description="(table) Column configurations." + ) + orientation: Literal["vertical", "horizontal"] | None = Field( + default=None, description="(repeat, menu) Orientation." + ) + repeat_items_per_row: ItemsPerRow | None = Field( + default=None, description="(repeat) Items per row config." + ) + + # -- Navigation fields (header, footer, menu) --------------------------- + + share_type: Literal["all", "only", "except"] | None = Field( + default=None, description="(header, footer) Page sharing." + ) + page_ids: list[int] | None = Field( + default=None, description="(header, footer) Page IDs for sharing." + ) + menu_orientation: Literal["horizontal", "vertical"] | None = Field( + default=None, description="(menu) Menu orientation." + ) + menu_alignment: Literal["left", "center", "right", "justify"] | None = Field( + default=None, description="(menu) Menu alignment." + ) + menu_items: list[MenuItemCreate] | None = Field( + default=None, description="(menu) Menu item configurations." + ) + + # -- Auth form fields --------------------------------------------------- + + user_source_id: int | None = Field( + default=None, + description="(auth_form) ID of the user source. Get it from setup_user_source.", + ) + login_button_label: str | None = Field( + default=None, description="(auth_form) Label for the login button." + ) + + # -- Properties --------------------------------------------------------- + + @property + def use_shared_page(self) -> bool: + """Whether this element should be created on the builder's shared page.""" + return self.type in _SHARED_PAGE_TYPES + + # -- ORM dispatch ------------------------------------------------------- + + def to_orm_kwargs(self, user: "AbstractUser", page: "Page") -> dict: + """Return kwargs for ``ElementService.create_element()``.""" + fn = _TO_ORM.get(self.type) + kwargs = fn(self, user, page) if fn else {} + + if self.visibility != "all": + kwargs["visibility"] = self.visibility + if self.role_type != "allow_all": + kwargs["role_type"] = self.role_type + if self.roles: + kwargs["roles"] = self.roles + + return kwargs + + def post_create( + self, + user: "AbstractUser", + orm_element: "Element", + page: "Page", + ) -> list: + """Hook called after ORM element creation. + + Returns a list of ``(orm_action, action_create)`` pairs for any + workflow actions created as part of the element setup (e.g. button + columns in table elements). Empty list if none. + """ + + fn = _POST_CREATE.get(self.type) + if fn: + return fn(self, user, orm_element, page) or [] + return [] + + def get_formulas_to_create( + self, + orm_element: "Element", + context: "BuilderFormulaContext", + ) -> dict[str, str]: + """Return ``{field_path: description}`` for LLM formula generation.""" + fn = _GET_FORMULAS.get(self.type) + return fn(self, orm_element, context) if fn else {} + + def update_with_formulas( + self, + user: "AbstractUser", + orm_element: "Element", + formulas: dict[str, str], + ) -> None: + """Apply LLM-generated formulas to this element.""" + fn = _UPDATE_FORMULAS.get(self.type) + if fn: + fn(self, user, orm_element, formulas) + else: + _update_simple_formulas(self, user, orm_element, formulas) + + +# --------------------------------------------------------------------------- +# ElementItem (for listing) +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Category-specific create models +# --------------------------------------------------------------------------- + + +class _ElementBase(BaseModel): + """Shared fields for all category-specific element create models.""" + + ref: str = Field(..., description="Unique reference for this element.") + parent_element: int | str | None = Field( + default=None, + description="Parent container: int ID (existing) or string ref (same batch).", + ) + place_in_container: str | None = Field( + default=None, description="Position in parent container (e.g. '0', '1')." + ) + + +class DisplayElementCreate(_ElementBase): + """ + Create display elements: heading, text, button, link, image. + + Use ``$formula:`` prefix for dynamic values (e.g. ``"$formula: the product name"``). + Static strings are auto-wrapped. + """ + + type: Literal["heading", "text", "button", "link", "image"] = Field( + ..., description="Element type." + ) + value: str | None = Field( + default=None, + description="Display text (heading, text, button label, link text). Supports $formula: prefix.", + ) + level: int | None = Field(default=None, description="(heading) Level 1-5.") + format: str | None = Field( + default=None, description="(text) 'plain' or 'markdown'." + ) + navigation_type: Literal["page", "custom"] | None = Field( + default=None, description="(link) Navigation type." + ) + navigate_to_page_id: int | None = Field( + default=None, description="(link) Target page ID." + ) + navigate_to_url: str | None = Field( + default=None, + description="(link) Custom URL. Supports $formula: prefix.", + ) + link_variant: Literal["link", "button"] | None = Field( + default=None, description="(link) Display variant." + ) + image_url: str | None = Field( + default=None, + description="(image) Image URL. Supports $formula: prefix.", + ) + alt_text: str | None = Field( + default=None, + description="(image) Alt text. Supports $formula: prefix.", + ) + + def to_element_item_create(self) -> "ElementItemCreate": + return ElementItemCreate( + ref=self.ref, + type=self.type, + parent_element=self.parent_element, + place_in_container=self.place_in_container, + value=self.value, + level=self.level, + format=self.format, + navigation_type=self.navigation_type, + navigate_to_page_id=self.navigate_to_page_id, + navigate_to_url=self.navigate_to_url, + link_variant=self.link_variant, + image_url=self.image_url, + alt_text=self.alt_text, + ) + + +class LayoutElementCreate(_ElementBase): + """ + Create layout/navigation elements: column, simple_container, header, footer, menu. + + Layout elements are containers — other elements go inside them via ``parent_element``. + """ + + type: Literal["column", "simple_container", "header", "footer", "menu"] = Field( + ..., description="Element type." + ) + column_amount: int | None = Field( + default=None, description="(column) Number of columns (1-6)." + ) + menu_items: list[MenuItemCreate] | None = Field( + default=None, description="(menu) Menu item configurations." + ) + share_type: Literal["all", "only", "except"] | None = Field( + default=None, + description="(header, footer) Page sharing: 'all' (default), 'only' (show on page_ids only), 'except' (hide on page_ids).", + ) + page_ids: list[int] | None = Field( + default=None, + description="(header, footer) Page IDs for share_type='only' or 'except'.", + ) + + def to_element_item_create(self) -> "ElementItemCreate": + return ElementItemCreate( + ref=self.ref, + type=self.type, + parent_element=self.parent_element, + place_in_container=self.place_in_container, + column_amount=self.column_amount, + menu_items=self.menu_items, + share_type=self.share_type, + page_ids=self.page_ids, + ) + + +class FormElementCreate(_ElementBase): + """ + Create form elements: form_container, input_text, choice, checkbox, datetime_picker, record_selector. + + Form inputs go inside a ``form_container`` (set ``parent_element`` to the form ref). + Use ``$formula:`` prefix for dynamic default values. + """ + + type: Literal[ + "form_container", + "input_text", + "choice", + "checkbox", + "datetime_picker", + "record_selector", + ] = Field(..., description="Element type.") + label: str | None = Field(default=None, description="(form inputs) Field label.") + placeholder: str | None = Field( + default=None, description="(form inputs) Placeholder text." + ) + default_value: str | None = Field( + default=None, + description="(form inputs) Default value. Supports $formula: prefix.", + ) + required: bool | None = Field( + default=None, description="(form inputs) Required field." + ) + validation_type: Literal["any", "email", "integer"] | None = Field( + default=None, description="(input_text) Validation type." + ) + is_multiline: bool | None = Field( + default=None, description="(input_text) Multiline mode." + ) + multiple: bool | None = Field( + default=None, description="(choice, record_selector) Allow multiple." + ) + choice_options: list[ChoiceOption] | None = Field( + default=None, description="(choice) List of options." + ) + include_time: bool | None = Field( + default=None, description="(datetime_picker) Include time." + ) + date_format: Literal["EU", "US", "ISO"] | None = Field( + default=None, description="(datetime_picker) Date format." + ) + submit_button_label: str | None = Field( + default=None, description="(form_container) Submit button label." + ) + data_source: int | str | None = Field( + default=None, + description="(record_selector) Data source: int ID or string ref.", + ) + + def to_element_item_create(self) -> "ElementItemCreate": + return ElementItemCreate( + ref=self.ref, + type=self.type, + parent_element=self.parent_element, + place_in_container=self.place_in_container, + label=self.label, + placeholder=self.placeholder, + default_value=self.default_value, + required=self.required, + validation_type=self.validation_type, + is_multiline=self.is_multiline, + multiple=self.multiple, + choice_options=self.choice_options, + include_time=self.include_time, + date_format=self.date_format, + submit_button_label=self.submit_button_label, + data_source=self.data_source, + ) + + +class CollectionElementCreate(_ElementBase): + """ + Create collection elements: table, repeat. + + These display data from a data source. Create data sources first, then reference them here. + """ + + type: Literal["table", "repeat"] = Field(..., description="Element type.") + data_source: int | str | None = Field( + default=None, + description="Data source: int ID (existing) or string ref (same batch).", + ) + fields: list[TableFieldConfig] | None = Field( + default=None, description="(table) Column configurations." + ) + orientation: Literal["vertical", "horizontal"] | None = Field( + default=None, description="(repeat) Orientation." + ) + + def to_element_item_create(self) -> "ElementItemCreate": + return ElementItemCreate( + ref=self.ref, + type=self.type, + parent_element=self.parent_element, + place_in_container=self.place_in_container, + data_source=self.data_source, + fields=self.fields, + orientation=self.orientation, + ) + + +# --------------------------------------------------------------------------- +# ElementUpdate (flat, for updating existing elements) +# --------------------------------------------------------------------------- + + +def _heading_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.value is not None: + kwargs["value"] = BaserowFormulaObject.create( + literal_or_placeholder(el.value) + if needs_formula(el.value) + else wrap_static_string(el.value), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if el.level is not None: + kwargs["level"] = el.level + return kwargs + + +def _text_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.value is not None: + kwargs["value"] = BaserowFormulaObject.create( + literal_or_placeholder(el.value) + if needs_formula(el.value) + else wrap_static_string(el.value), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if el.format is not None: + kwargs["format"] = el.format + return kwargs + + +def _button_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + text = el.value or el.label + if text is not None: + kwargs["value"] = BaserowFormulaObject.create( + literal_or_placeholder(text) + if needs_formula(text) + else wrap_static_string(text), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + return kwargs + + +def _link_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.value is not None: + kwargs["value"] = BaserowFormulaObject.create( + literal_or_placeholder(el.value) + if needs_formula(el.value) + else wrap_static_string(el.value), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if el.link_variant is not None: + kwargs["variant"] = el.link_variant + if el.navigation_type is not None: + kwargs["navigation_type"] = el.navigation_type + if el.link_target is not None: + kwargs["target"] = el.link_target + if el.navigate_to_page_id is not None: + kwargs["navigate_to_page_id"] = el.navigate_to_page_id + if el.navigate_to_url is not None: + kwargs["navigate_to_url"] = BaserowFormulaObject.create( + literal_or_placeholder(el.navigate_to_url) + if needs_formula(el.navigate_to_url) + else wrap_static_string(el.navigate_to_url), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + return kwargs + + +def _image_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.image_source_type is not None: + kwargs["image_source_type"] = el.image_source_type + if el.image_url is not None: + kwargs["image_url"] = BaserowFormulaObject.create( + literal_or_placeholder(el.image_url) + if needs_formula(el.image_url) + else wrap_static_string(el.image_url), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if el.alt_text is not None: + kwargs["alt_text"] = BaserowFormulaObject.create( + literal_or_placeholder(el.alt_text) + if needs_formula(el.alt_text) + else wrap_static_string(el.alt_text), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + return kwargs + + +def _column_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.column_amount is not None: + kwargs["column_amount"] = el.column_amount + if el.column_gap is not None: + kwargs["column_gap"] = el.column_gap + if el.column_alignment is not None: + kwargs["alignment"] = el.column_alignment + return kwargs + + +def _form_container_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.submit_button_label is not None: + kwargs["submit_button_label"] = BaserowFormulaObject.create( + f"'{el.submit_button_label}'", + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + return kwargs + + +def _input_text_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.label is not None: + kwargs["label"] = BaserowFormulaObject.create( + f"'{el.label}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ) + if el.placeholder is not None: + kwargs["placeholder"] = BaserowFormulaObject.create( + f"'{el.placeholder}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ) + if el.default_value is not None: + kwargs["default_value"] = BaserowFormulaObject.create( + literal_or_placeholder(el.default_value) + if needs_formula(el.default_value) + else (wrap_static_string(el.default_value) if el.default_value else "''"), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if el.required is not None: + kwargs["required"] = el.required + if el.validation_type is not None: + kwargs["validation_type"] = el.validation_type + if el.is_multiline is not None: + kwargs["is_multiline"] = el.is_multiline + if el.rows is not None: + kwargs["rows"] = el.rows + return kwargs + + +def _choice_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.label is not None: + kwargs["label"] = BaserowFormulaObject.create( + f"'{el.label}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ) + if el.placeholder is not None: + kwargs["placeholder"] = BaserowFormulaObject.create( + f"'{el.placeholder}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ) + if el.default_value is not None: + kwargs["default_value"] = BaserowFormulaObject.create( + literal_or_placeholder(el.default_value) + if needs_formula(el.default_value) + else (wrap_static_string(el.default_value) if el.default_value else "''"), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if el.required is not None: + kwargs["required"] = el.required + if el.multiple is not None: + kwargs["multiple"] = el.multiple + if el.show_as_dropdown is not None: + kwargs["show_as_dropdown"] = el.show_as_dropdown + return kwargs + + +def _checkbox_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.label is not None: + kwargs["label"] = BaserowFormulaObject.create( + f"'{el.label}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ) + if el.default_value is not None: + kwargs["default_value"] = BaserowFormulaObject.create( + literal_or_placeholder(el.default_value) + if needs_formula(el.default_value) + else el.default_value, + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if el.required is not None: + kwargs["required"] = el.required + return kwargs + + +def _datetime_picker_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.label is not None: + kwargs["label"] = BaserowFormulaObject.create( + f"'{el.label}'", mode=BASEROW_FORMULA_MODE_ADVANCED + ) + if el.default_value is not None: + kwargs["default_value"] = BaserowFormulaObject.create( + literal_or_placeholder(el.default_value) + if needs_formula(el.default_value) + else (wrap_static_string(el.default_value) if el.default_value else "''"), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + if el.required is not None: + kwargs["required"] = el.required + if el.include_time is not None: + kwargs["include_time"] = el.include_time + if el.date_format is not None: + kwargs["date_format"] = el.date_format + return kwargs + + +def _repeat_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.orientation is not None: + kwargs["orientation"] = el.orientation + if el.items_per_page is not None: + kwargs["items_per_page"] = el.items_per_page + return kwargs + + +def _table_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.items_per_page is not None: + kwargs["items_per_page"] = el.items_per_page + if el.button_load_more_label is not None: + kwargs["button_load_more_label"] = BaserowFormulaObject.create( + f"'{el.button_load_more_label}'", + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + has_field_change = ( + el.fields is not None + or el.add_fields is not None + or el.remove_fields is not None + ) + if has_field_change: + from baserow.contrib.builder.elements.service import ElementHandler + + try: + element = ElementHandler().get_element(el.element_id).specific + ds_id = getattr(element, "data_source_id", None) + except Exception: + element = None + ds_id = None + + if el.fields is not None: + # Full replace + kwargs["fields"] = _convert_table_fields( + data_source_id=ds_id, fields=el.fields + ) + else: + # Incremental: start from existing fields + existing = [] + if element is not None and hasattr(element, "fields"): + for f in element.fields.order_by("order"): + existing.append( + { + "name": f.name, + "type": f.type, + "config": f.config, + "uid": str(f.uid), + } + ) + + # Remove by name (case-insensitive) + if el.remove_fields: + remove_set = {n.lower() for n in el.remove_fields} + existing = [f for f in existing if f["name"].lower() not in remove_set] + + # Append new columns + if el.add_fields: + new_fields = _convert_table_fields( + data_source_id=ds_id, fields=el.add_fields + ) + existing.extend(new_fields) + + kwargs["fields"] = existing + return kwargs + + +def _header_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.share_type is not None: + kwargs["share_type"] = el.share_type + return kwargs + + +def _menu_update(el: "ElementUpdate") -> dict: + kwargs: dict[str, Any] = {} + if el.menu_orientation is not None: + kwargs["orientation"] = el.menu_orientation + if el.menu_alignment is not None: + kwargs["alignment"] = el.menu_alignment + if el.menu_items is not None: + kwargs["menu_items"] = [ + { + "uid": str(uuid.uuid4()), + "type": "link", + "variant": "link", + "name": item.name, + "navigation_type": "page", + "navigate_to_page_id": item.page_id, + "target": "self", + } + for item in el.menu_items + ] + return kwargs + + +_TO_ORM_UPDATE: dict[str, Any] = { + "heading": _heading_update, + "text": _text_update, + "button": _button_update, + "link": _link_update, + "image": _image_update, + "column": _column_update, + "form_container": _form_container_update, + "simple_container": lambda el: {}, + "input_text": _input_text_update, + "choice": _choice_update, + "checkbox": _checkbox_update, + "datetime_picker": _datetime_picker_update, + "record_selector": _input_text_update, + "table": _table_update, + "repeat": _repeat_update, + "header": _header_update, + "footer": _header_update, + "menu": _menu_update, +} + + +def _update_value_formula(el: "ElementUpdate", orm_element, context) -> dict[str, str]: + if el.value and needs_formula(el.value): + return {"value": formula_desc(el.value)} + return {} + + +def _update_link_formulas(el: "ElementUpdate", orm_element, context) -> dict[str, str]: + formulas: dict[str, str] = {} + if el.value and needs_formula(el.value): + formulas["value"] = formula_desc(el.value) + if el.navigate_to_url and needs_formula(el.navigate_to_url): + formulas["navigate_to_url"] = formula_desc(el.navigate_to_url) + return formulas + + +def _update_image_formulas(el: "ElementUpdate", orm_element, context) -> dict[str, str]: + formulas: dict[str, str] = {} + if el.image_url and needs_formula(el.image_url): + formulas["image_url"] = formula_desc(el.image_url) + if el.alt_text and needs_formula(el.alt_text): + formulas["alt_text"] = formula_desc(el.alt_text) + return formulas + + +def _update_default_value_formula( + el: "ElementUpdate", orm_element, context +) -> dict[str, str]: + if el.default_value and needs_formula(el.default_value): + return {"default_value": formula_desc(el.default_value)} + return {} + + +_GET_UPDATE_FORMULAS: dict[str, Any] = { + "heading": _update_value_formula, + "text": _update_value_formula, + "button": _update_value_formula, + "link": _update_link_formulas, + "image": _update_image_formulas, + "input_text": _update_default_value_formula, + "choice": _update_default_value_formula, + "checkbox": _update_default_value_formula, + "datetime_picker": _update_default_value_formula, +} + + +class ElementUpdate(BaseModel): + """ + Flat model for updating an existing builder UI element. + + All fields are optional. Only non-None fields are sent to the service layer. + The element type is read from the database, not passed by the LLM. + """ + + element_id: int = Field(..., description="ID of the element to update.") + + # -- Common --------------------------------------------------------------- + visibility: Literal["all", "logged-in", "not-logged"] | None = Field( + default=None, description="Element visibility." + ) + role_type: ( + Literal["allow_all", "allow_all_except", "disallow_all_except"] | None + ) = Field(default=None, description="Role access strategy.") + roles: list[str] | None = Field( + default=None, description="Role names for the access strategy." + ) + + # -- Display fields ------------------------------------------------------- + value: str | None = Field( + default=None, + description="Display text (heading, text, button, link). Supports $formula: prefix.", + ) + level: int | None = Field(default=None, description="(heading) Level 1-5.") + format: str | None = Field( + default=None, description="(text) 'plain' or 'markdown'." + ) + + # -- Link fields ---------------------------------------------------------- + link_variant: Literal["link", "button"] | None = Field( + default=None, description="(link) Display variant." + ) + navigation_type: Literal["page", "custom"] | None = Field( + default=None, description="(link) Navigation type." + ) + navigate_to_page_id: int | None = Field( + default=None, description="(link) Target page ID." + ) + navigate_to_url: str | None = Field( + default=None, + description="(link) Custom URL. Supports $formula: prefix.", + ) + link_target: Literal["self", "blank"] | None = Field( + default=None, description="(link) Navigation target." + ) + + # -- Image fields --------------------------------------------------------- + image_source_type: Literal["upload", "url"] | None = Field( + default=None, description="(image) Source type." + ) + image_url: str | None = Field( + default=None, + description="(image) Image URL. Supports $formula: prefix.", + ) + alt_text: str | None = Field( + default=None, + description="(image) Alt text. Supports $formula: prefix.", + ) + + # -- Column fields -------------------------------------------------------- + column_amount: int | None = Field( + default=None, description="(column) Number of columns (1-6)." + ) + column_gap: int | None = Field( + default=None, description="(column) Gap between columns in px." + ) + column_alignment: Literal["top", "center", "bottom"] | None = Field( + default=None, description="(column) Vertical alignment." + ) + + # -- Form container fields ------------------------------------------------ + submit_button_label: str | None = Field( + default=None, description="(form_container) Submit button label." + ) + + # -- Form input fields ---------------------------------------------------- + label: str | None = Field(default=None, description="(form inputs) Field label.") + placeholder: str | None = Field( + default=None, description="(form inputs) Placeholder text." + ) + default_value: str | None = Field( + default=None, + description="(form inputs) Default value. Supports $formula: prefix.", + ) + required: bool | None = Field( + default=None, description="(form inputs) Required field." + ) + validation_type: Literal["any", "email", "integer"] | None = Field( + default=None, description="(input_text) Validation type." + ) + is_multiline: bool | None = Field( + default=None, description="(input_text) Multiline mode." + ) + rows: int | None = Field( + default=None, description="(input_text) Rows for multiline." + ) + multiple: bool | None = Field( + default=None, description="(choice, record_selector) Allow multiple." + ) + show_as_dropdown: bool | None = Field( + default=None, description="(choice) Show as dropdown." + ) + include_time: bool | None = Field( + default=None, description="(datetime_picker) Include time." + ) + date_format: Literal["EU", "US", "ISO"] | None = Field( + default=None, description="(datetime_picker) Date format." + ) + + # -- Collection fields ---------------------------------------------------- + items_per_page: int | None = Field( + default=None, description="(table, repeat) Items per page." + ) + button_load_more_label: str | None = Field( + default=None, description="(table) Load more button label." + ) + fields: list[TableFieldConfig] | None = Field( + default=None, + description="(table) Replace ALL columns — use only when you want to redefine the entire column list. Prefer add_fields/remove_fields for incremental changes.", + ) + add_fields: list[TableFieldConfig] | None = Field( + default=None, + description="(table) Append columns to the existing table. Existing columns are preserved.", + ) + remove_fields: list[str] | None = Field( + default=None, + description="(table) Remove columns by name (case-insensitive). Remaining columns are preserved.", + ) + orientation: Literal["vertical", "horizontal"] | None = Field( + default=None, description="(repeat) Orientation." + ) + + # -- Navigation fields ---------------------------------------------------- + share_type: Literal["all", "only", "except"] | None = Field( + default=None, description="(header, footer) Page sharing." + ) + menu_orientation: Literal["horizontal", "vertical"] | None = Field( + default=None, description="(menu) Menu orientation." + ) + menu_alignment: Literal["left", "center", "right", "justify"] | None = Field( + default=None, description="(menu) Menu alignment." + ) + menu_items: list[MenuItemCreate] | None = Field( + default=None, + description="(menu) Replace all menu items. Each item has name + page_id.", + ) + + # -- Dispatch ------------------------------------------------------------- + + def to_update_kwargs(self, element_type: str) -> dict: + """Return kwargs for ``ElementService.update_element()``.""" + + fn = _TO_ORM_UPDATE.get(element_type) + kwargs = fn(self) if fn else {} + + # Handle visibility (common to all types) + if self.visibility is not None: + kwargs["visibility"] = self.visibility + if self.role_type is not None: + kwargs["role_type"] = self.role_type + if self.roles is not None: + kwargs["roles"] = self.roles + + return kwargs + + def get_formulas_to_update( + self, + orm_element: "Element", + context: "BuilderFormulaContext", + element_type: str, + ) -> dict[str, str]: + """Return ``{field_path: description}`` for LLM formula generation.""" + + fn = _GET_UPDATE_FORMULAS.get(element_type) + return fn(self, orm_element, context) if fn else {} + + def get_updated_field_names(self) -> list[str]: + """Return names of fields that were explicitly set (non-None).""" + + skip = {"element_id"} + return [ + name + for name, field_info in self.__class__.model_fields.items() + if name not in skip and getattr(self, name) is not None + ] + + +class ElementItem(BaseModel): + """Existing element with ID.""" + + id: int + type: str + order: str + parent_element_id: int | None = None + place_in_container: str | None = None + is_container: bool = Field( + default=False, + description="True if this element can contain child elements.", + ) + label: str | None = Field( + default=None, + description="Short content preview (text value, label, or name).", + ) + page_name: str | None = Field( + default=None, + description="Page name. '[shared]' for elements on the shared page (headers/footers).", + ) + menu_items: list[dict] | None = Field( + default=None, + description="(menu) Current menu items with name and page_id.", + ) + + @classmethod + def from_orm(cls, element) -> "ElementItem": + """Create ElementItem from ORM Element instance.""" + element_type = element.get_type().type + page = element.page + page_name = "[shared]" if page.shared else page.name + menu_items = None + if element_type == "menu": + specific = element.specific + menu_items = [ + { + "name": item.name, + "page_id": item.navigate_to_page_id, + "type": item.type, + } + for item in specific.menu_items.all().order_by("menu_item_order") + ] + return cls( + id=element.id, + type=element_type, + order=str(element.order), + parent_element_id=element.parent_element_id, + place_in_container=element.place_in_container, + is_container=element_type in CONTAINER_ELEMENT_TYPES, + label=cls._extract_label(element), + page_name=page_name, + menu_items=menu_items, + ) + + @staticmethod + def _extract_label(element) -> str | None: + """Extract a short content preview from the element's ORM fields. + + FormulaField values are ``BaserowFormulaObject`` dicts with a + ``formula`` key; plain strings are also possible for legacy data. + """ + + # Display elements (heading, text, button, link) use ``value`` + # Form inputs (input_text, choice, checkbox) use ``label`` + raw = getattr(element, "value", None) or getattr(element, "label", None) + if not raw: + return None + # FormulaField returns a dict like {"formula": "...", "mode": ..., "version": ...} + if isinstance(raw, dict): + raw = raw.get("formula", "") or raw.get("f", "") + if not isinstance(raw, str) or not raw.strip(): + return None + preview = raw.strip() + if len(preview) > 80: + preview = preview[:77] + "..." + return preview + + +# --------------------------------------------------------------------------- +# Element move model +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Style override block types +# --------------------------------------------------------------------------- + + +class ButtonStyleOverride(BaseModel): + """Per-element button style overrides.""" + + model_config = {"extra": "forbid"} + + background_color: str | None = Field(default=None, description="Button bg color.") + text_color: str | None = Field(default=None, description="Button text color.") + border_color: str | None = Field(default=None, description="Button border color.") + border_size: int | None = Field(default=None) + border_radius: int | None = Field(default=None) + hover_background_color: str | None = Field(default=None) + hover_text_color: str | None = Field(default=None) + font_size: int | None = Field(default=None) + width: Literal["auto", "full"] | None = Field(default=None) + alignment: Literal["left", "center", "right"] | None = Field(default=None) + + def to_styles_dict(self) -> dict: + return {f"button_{k}": v for k, v in self.model_dump(exclude_none=True).items()} + + +class LinkStyleOverride(BaseModel): + """Per-element link style overrides.""" + + model_config = {"extra": "forbid"} + + text_color: str | None = Field(default=None) + hover_text_color: str | None = Field(default=None) + font_size: int | None = Field(default=None) + font_weight: str | None = Field(default=None) + + def to_styles_dict(self) -> dict: + return {f"link_{k}": v for k, v in self.model_dump(exclude_none=True).items()} + + +class TypographyStyleOverride(BaseModel): + """Per-element typography overrides (heading/text elements).""" + + model_config = {"extra": "forbid"} + + heading_1_text_color: str | None = Field(default=None) + heading_1_font_size: int | None = Field(default=None) + heading_1_font_weight: str | None = Field(default=None) + heading_1_text_alignment: Literal["left", "center", "right"] | None = Field( + default=None + ) + body_text_color: str | None = Field(default=None) + body_font_size: int | None = Field(default=None) + body_font_weight: str | None = Field(default=None) + body_text_alignment: Literal["left", "center", "right"] | None = Field(default=None) + + def to_styles_dict(self) -> dict: + return self.model_dump(exclude_none=True) + + +class InputStyleOverride(BaseModel): + """Per-element input style overrides.""" + + model_config = {"extra": "forbid"} + + input_background_color: str | None = Field(default=None) + input_border_color: str | None = Field(default=None) + input_text_color: str | None = Field(default=None) + input_border_size: int | None = Field(default=None) + input_border_radius: int | None = Field(default=None) + label_text_color: str | None = Field(default=None) + + def to_styles_dict(self) -> dict: + return self.model_dump(exclude_none=True) + + +class TableStyleOverride(BaseModel): + """Per-element table style overrides.""" + + model_config = {"extra": "forbid"} + + table_header_background_color: str | None = Field(default=None) + table_header_text_color: str | None = Field(default=None) + table_cell_background_color: str | None = Field(default=None) + table_border_color: str | None = Field(default=None) + table_border_size: int | None = Field(default=None) + + def to_styles_dict(self) -> dict: + return self.model_dump(exclude_none=True) + + +class ImageStyleOverride(BaseModel): + """Per-element image style overrides.""" + + model_config = {"extra": "forbid"} + + image_alignment: Literal["left", "center", "right"] | None = Field(default=None) + image_max_width: int | None = Field(default=None) + image_max_height: int | None = Field(default=None) + image_border_radius: int | None = Field(default=None) + + def to_styles_dict(self) -> dict: + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# Style update model +# --------------------------------------------------------------------------- + +_STYLE_DEFAULTS: dict[str, Any] = { + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_top": 10, + "style_padding_bottom": 10, + "style_padding_left": 20, + "style_padding_right": 20, + "style_margin_top": 0, + "style_margin_bottom": 0, + "style_margin_left": 0, + "style_margin_right": 0, + "style_border_radius": 0, + "style_background_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_mode": "fill", + "style_width": "normal", +} + +# Maps (element_type, tool_block_name) → styles JSON key. +# The styles JSON key is the serializer's `property_name`, which varies per +# element type. E.g. for "menu", both button and link props go under "menu". +_BLOCK_TO_STYLES_KEY: dict[str, dict[str, str]] = { + "heading": {"typography": "typography"}, + "text": {"typography": "typography"}, + "button": {"button": "button"}, + "link": {"button": "button", "link": "link"}, + "image": {"image": "image"}, + "input_text": {"input": "input"}, + "choice": {"input": "input"}, + "checkbox": {"input": "input"}, + "table": {"button": "button", "table": "table"}, + "repeat": {"button": "button"}, + "menu": {"button": "menu", "link": "menu"}, + "form_container": {"button": "button"}, +} + + +class ElementStyleUpdate(BaseModel): + """ + Compact model for updating element visual styles. + + All fields optional — only non-None fields are applied. + Set reset=true to restore all styles to defaults first. + """ + + element_id: int = Field(..., description="ID of the element to style.") + reset: bool = Field( + default=False, description="Reset all styles to defaults first." + ) + + # -- Box model: single value for all sides, or dict for per-side -- + border_color: str | dict[str, str] | None = Field( + default=None, + description='Border color: value for all sides, or {"left": "#ff0000", ...} for specific sides.', + ) + border_size: int | dict[str, int] | None = Field( + default=None, + description='Border size in px: value for all sides, or {"top": 2, ...} for specific sides.', + ) + padding: int | dict[str, int] | None = Field( + default=None, + description='Padding in px: value for all sides, or {"left": 0, ...} for specific sides.', + ) + margin: int | dict[str, int] | None = Field( + default=None, + description='Margin in px: value for all sides, or {"top": 10, ...} for specific sides.', + ) + + @model_validator(mode="after") + def _validate_box_sides(self) -> "ElementStyleUpdate": + valid_sides = {"top", "bottom", "left", "right"} + for field_name in ("padding", "margin", "border_size", "border_color"): + val = getattr(self, field_name) + if isinstance(val, dict): + invalid = set(val.keys()) - valid_sides + if invalid: + raise ValueError(f"{field_name}: invalid sides {invalid}") + return self + + # -- Radii -- + border_radius: int | None = Field(default=None) + background_radius: int | None = Field(default=None) + + # -- Background -- + background: Literal["none", "color"] | None = Field(default=None) + background_color: str | None = Field( + default=None, description="Background color (hex)." + ) + + # -- Width -- + width: Literal["full", "full-width", "normal", "medium", "small"] | None = Field( + default=None + ) + + # -- Theme style overrides (per element type) -- + button: ButtonStyleOverride | None = Field( + default=None, description="Button style overrides." + ) + link: LinkStyleOverride | None = Field( + default=None, description="Link style overrides." + ) + typography: TypographyStyleOverride | None = Field( + default=None, description="Typography overrides." + ) + input: InputStyleOverride | None = Field( + default=None, description="Input style overrides." + ) + table: TableStyleOverride | None = Field( + default=None, description="Table style overrides." + ) + image: ImageStyleOverride | None = Field( + default=None, description="Image style overrides." + ) + + def _apply_box(self, kwargs: dict, field_name: str, orm_template: str): + val = getattr(self, field_name) + if val is None: + return + if isinstance(val, dict): + for side, side_val in val.items(): + kwargs[orm_template.format(side=side)] = side_val + else: + for side in ("top", "bottom", "left", "right"): + kwargs[orm_template.format(side=side)] = val + + def to_update_kwargs( + self, element_type: str, existing_styles: dict | None = None + ) -> dict: + """Convert to ORM kwargs for ElementService.update_element(). + + :param element_type: The element's type string (e.g. "button"). + :param existing_styles: The element's current ``styles`` JSON dict. + Used to merge theme overrides without wiping unrelated keys. + """ + + kwargs: dict[str, Any] = {} + + if self.reset: + kwargs.update(_STYLE_DEFAULTS) + kwargs["styles"] = {} + + # Box model — uniform or per-side + self._apply_box(kwargs, "border_color", "style_border_{side}_color") + self._apply_box(kwargs, "border_size", "style_border_{side}_size") + self._apply_box(kwargs, "padding", "style_padding_{side}") + self._apply_box(kwargs, "margin", "style_margin_{side}") + + # Simple style fields + if self.border_radius is not None: + kwargs["style_border_radius"] = self.border_radius + if self.background_radius is not None: + kwargs["style_background_radius"] = self.background_radius + if self.background is not None: + kwargs["style_background"] = self.background + if self.background_color is not None: + kwargs["style_background_color"] = self.background_color + if self.width is not None: + kwargs["style_width"] = self.width + + # Theme style overrides — merge into existing styles JSON. + # Start from existing styles (or {} after reset) so we don't wipe + # keys that weren't touched in this call. + block_key_map = _BLOCK_TO_STYLES_KEY.get(element_type, {}) + if self.reset: + styles: dict = {} + else: + styles = dict(existing_styles) if existing_styles else {} + # Deep-copy block dicts so we don't mutate the original + for k, v in styles.items(): + if isinstance(v, dict): + styles[k] = dict(v) + styles_changed = False + for block_name in ("button", "link", "typography", "input", "table", "image"): + override = getattr(self, block_name) + target_key = block_key_map.get(block_name) + if override is not None and target_key is not None: + block_dict = override.to_styles_dict() + if block_dict: + styles[target_key] = { + **styles.get(target_key, {}), + **block_dict, + } + styles_changed = True + if styles_changed or "styles" in kwargs: + kwargs["styles"] = styles + + return kwargs + + +class ElementMove(BaseModel): + """Describes a single element move operation.""" + + element_id: int = Field(description="ID of the element to move.") + before_id: int | None = Field( + default=None, + description="Place before this element ID. None = move to end.", + ) + parent_element_id: int | None = Field( + default=None, + description="New parent element ID. None = move to root level.", + ) + place_in_container: str | None = Field( + default=None, + description='Container slot (e.g. "0", "1" for columns). None = default.', + ) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/page.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/page.py new file mode 100644 index 0000000000..a68865a343 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/page.py @@ -0,0 +1,153 @@ +""" +Builder page type models. + +Defines ``PageCreate`` for creating pages and ``PageItem`` for reading them back. +""" + +from typing import Literal + +from pydantic import Field + +from baserow_enterprise.assistant.types import BaseModel + +RoleType = Literal["allow_all", "allow_all_except", "disallow_all_except"] + + +class PagePathParam(BaseModel): + """A path parameter definition (e.g. ``id`` in ``/products/:id``).""" + + name: str = Field(..., description="Parameter name.") + type: Literal["text", "numeric"] = Field("text", description="Parameter type.") + + +class PageQueryParam(BaseModel): + """A query parameter definition.""" + + name: str = Field(..., description="Parameter name.") + type: Literal["text", "numeric"] = Field("text", description="Parameter type.") + + +class PageCreate(BaseModel): + """Page creation payload.""" + + name: str = Field(..., description="Page name (unique in app).") + path: str = Field(..., description="URL path, e.g. '/products/:id'.") + path_params: list[PagePathParam] = Field( + default_factory=list, description="Path parameters." + ) + query_params: list[PageQueryParam] = Field( + default_factory=list, description="Query parameters." + ) + visibility: Literal["all", "logged-in"] = Field( + "all", description="'all' or 'logged-in'." + ) + role_type: RoleType = Field( + "allow_all", + description=( + "Role access strategy. Only relevant when visibility='logged-in'. " + "Use list_pages to see available_roles." + ), + ) + roles: list[str] = Field( + default_factory=list, + description="Role names for the access strategy.", + ) + + +class PageUpdate(BaseModel): + """ + Update an existing page's properties. + + All fields are optional. Only non-None fields are sent to the service layer. + """ + + page_id: int = Field(..., description="ID of the page to update.") + name: str | None = Field(default=None, description="New page name.") + path: str | None = Field(default=None, description="New URL path.") + path_params: list[PagePathParam] | None = Field( + default=None, description="New path parameters." + ) + query_params: list[PageQueryParam] | None = Field( + default=None, description="New query parameters." + ) + visibility: Literal["all", "logged-in"] | None = Field( + default=None, description="Page visibility." + ) + role_type: RoleType | None = Field( + default=None, description="Role access strategy." + ) + roles: list[str] | None = Field( + default=None, description="Role names for the access strategy." + ) + + def to_update_kwargs(self) -> dict: + """Return kwargs for ``PageService.update_page()``.""" + + kwargs: dict = {} + if self.name is not None: + kwargs["name"] = self.name + if self.path is not None: + kwargs["path"] = self.path + if self.path_params is not None: + kwargs["path_params"] = [p.model_dump() for p in self.path_params] + if self.query_params is not None: + kwargs["query_params"] = [q.model_dump() for q in self.query_params] + if self.visibility is not None: + kwargs["visibility"] = self.visibility + if self.role_type is not None: + kwargs["role_type"] = self.role_type + if self.roles is not None: + kwargs["roles"] = self.roles + return kwargs + + def get_updated_field_names(self) -> list[str]: + """Return names of fields that were explicitly set (non-None).""" + + skip = {"page_id"} + return [ + name + for name in self.__class__.model_fields + if name not in skip and getattr(self, name) is not None + ] + + +class PageItem(BaseModel): + """Existing page with ID.""" + + id: int + name: str + path: str + path_params: list[PagePathParam] = Field(default_factory=list) + query_params: list[PageQueryParam] = Field(default_factory=list) + visibility: str = "all" + role_type: str = "allow_all" + roles: list[str] = Field(default_factory=list) + + @classmethod + def from_orm(cls, page) -> "PageItem": + """Create a PageItem from a Django Page instance.""" + + path_params = [] + for p in page.path_params or []: + if isinstance(p, dict): + path_params.append( + PagePathParam(name=p["name"], type=p.get("type", "text")) + ) + + query_params = [] + for q in page.query_params or []: + if isinstance(q, dict): + query_params.append( + PageQueryParam(name=q["name"], type=q.get("type", "text")) + ) + + return cls( + id=page.id, + name=page.name, + path=page.path, + path_params=path_params, + query_params=query_params, + visibility=page.visibility, + role_type=page.role_type, + roles=page.roles or [], + ) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/user_source.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/user_source.py new file mode 100644 index 0000000000..7e493b8dd6 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/user_source.py @@ -0,0 +1,52 @@ +""" +Builder user source type models. + +Defines ``UserSourceSetup`` for creating user sources with their backing +tables and authentication providers. +""" + +from pydantic import Field, model_validator + +from baserow_enterprise.assistant.types import BaseModel + +_DEFAULT_ROLES = ["Admin", "Member", "Viewer"] + + +class UserSourceSetup(BaseModel): + """ + Set up a user source for the application. + + Exactly one of ``table_id`` or ``database_id`` must be provided. + """ + + name: str = Field(..., description="Name for the user source.") + + table_id: int | None = Field( + None, description="Existing table ID to use as user source." + ) + database_id: int | None = Field( + None, description="Database ID to create a new users table in." + ) + + roles: list[str] | None = Field( + None, + description=( + "Role names for the SingleSelect role field. " + 'Defaults to ["Admin", "Member", "Viewer"]. ' + "Only used when creating a new table." + ), + ) + + @model_validator(mode="after") + def _check_table_or_database(self): + if not self.table_id and not self.database_id: + raise ValueError("One of 'table_id' or 'database_id' is required.") + if self.table_id and self.database_id: + raise ValueError( + "Only one of 'table_id' or 'database_id' can be provided, not both." + ) + return self + + def get_roles(self) -> list[str]: + """Return the roles to use, falling back to defaults.""" + return self.roles or list(_DEFAULT_ROLES) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/workflow_action.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/workflow_action.py new file mode 100644 index 0000000000..e27feebe14 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/builder/types/workflow_action.py @@ -0,0 +1,504 @@ +""" +Builder workflow action type models. + +Defines ``ActionCreate`` (flat) for creating workflow actions and +``ActionItem`` for reading them back. +""" + +from typing import TYPE_CHECKING, Any, Literal + +from pydantic import Field, model_validator + +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_ADVANCED, + BaserowFormulaObject, +) +from baserow_enterprise.assistant.tools.shared.formula_utils import ( + formula_desc, + literal_or_placeholder, + needs_formula, +) +from baserow_enterprise.assistant.types import BaseModel + +if TYPE_CHECKING: + from baserow.contrib.builder.workflow_actions.models import BuilderWorkflowAction + from baserow_enterprise.assistant.tools.builder.agents import BuilderFormulaContext + +# --------------------------------------------------------------------------- +# Sub-models +# --------------------------------------------------------------------------- + +ActionType = Literal[ + "notification", + "open_page", + "create_row", + "update_row", + "delete_row", + "refresh_data_source", + "logout", +] + + +class ParameterMapping(BaseModel): + """Key-value parameter mapping for page/query parameters.""" + + name: str = Field(..., description="Parameter name.") + value: str = Field(..., description="Parameter value formula.") + + +class FieldValueMapping(BaseModel): + """Field-value mapping for row create/update actions.""" + + field_id: str = Field(..., description="The field ID (as string).") + value: str = Field( + ..., + description="Value or $formula: prefix + formula intent.", + ) + + +# --------------------------------------------------------------------------- +# Required fields per action type +# --------------------------------------------------------------------------- + +_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { + "notification": ("title",), + "open_page": ("navigate_to_page_id",), + "create_row": ("table_id", "field_values"), + "update_row": ("table_id", "row_id", "field_values"), + "delete_row": ("table_id", "row_id"), +} + + +def _strip_formula_prefix(value: str) -> str: + """ + Strip the ``$formula:`` prefix if present, returning the inner formula. + + The LLM sometimes adds the prefix to values that are already valid formulas + (e.g. ``$formula: get('current_record.id')``). In contexts where the value + is used directly (not routed through formula generation), we strip the + prefix so the underlying formula is used as-is. + """ + + if needs_formula(value): + return formula_desc(value) + return value + + +# --------------------------------------------------------------------------- +# ORM dispatch: action_type -> kwargs builder +# --------------------------------------------------------------------------- + + +def _notification_orm_kwargs(action: "ActionCreate") -> dict: + return { + "title": BaserowFormulaObject.create( + literal_or_placeholder(action.title), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + "description": BaserowFormulaObject.create( + literal_or_placeholder(action.description), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + } + + +def _open_page_orm_kwargs(action: "ActionCreate") -> dict: + return { + "navigation_type": "page", + "navigate_to_page_id": action.navigate_to_page_id, + "page_parameters": [ + { + "name": p.name, + "value": BaserowFormulaObject.create( + literal_or_placeholder(p.value), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + } + for p in (action.page_parameters or []) + ], + "query_parameters": [ + { + "name": p.name, + "value": BaserowFormulaObject.create( + literal_or_placeholder(p.value), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ), + } + for p in (action.query_parameters or []) + ], + "target": action.target, + } + + +def _refresh_ds_orm_kwargs(action: "ActionCreate") -> dict: + if isinstance(action.data_source, int): + return {"data_source_id": action.data_source} + return {} + + +_TO_ORM_KWARGS: dict[str, Any] = { + "notification": _notification_orm_kwargs, + "open_page": _open_page_orm_kwargs, + "refresh_data_source": _refresh_ds_orm_kwargs, + "logout": lambda _: {}, +} + +# --------------------------------------------------------------------------- +# Service type dispatch +# --------------------------------------------------------------------------- + +_SERVICE_TYPE: dict[str, str | None] = { + "notification": None, + "open_page": None, + "create_row": "local_baserow_upsert_row", + "update_row": "local_baserow_upsert_row", + "delete_row": "local_baserow_delete_row", + "refresh_data_source": None, + "logout": None, +} + + +def _row_service_kwargs(action: "ActionCreate", user, workspace) -> dict: + """Build service kwargs for row-based actions (create/update/delete).""" + + from baserow_enterprise.assistant.tools.builder.helpers import ToolInputError + from baserow_enterprise.assistant.tools.database.helpers import filter_tables + + table = filter_tables(user, workspace).filter(id=action.table_id).first() + if table is None: + raise ToolInputError(f"Table with id {action.table_id} not found.") + kwargs: dict[str, Any] = {"table": table} + + if action.type in ("update_row", "delete_row") and action.row_id: + kwargs["row_id"] = BaserowFormulaObject.create( + _strip_formula_prefix(action.row_id), + mode=BASEROW_FORMULA_MODE_ADVANCED, + ) + + return kwargs + + +# --------------------------------------------------------------------------- +# Field mapping dispatch +# --------------------------------------------------------------------------- + + +def _field_mappings(action: "ActionCreate") -> list[dict] | None: + """Build field mappings for create/update row actions.""" + + if action.type not in ("create_row", "update_row") or not action.field_values: + return None + + mappings = [] + for fv in action.field_values: + if needs_formula(fv.value): + formula_value = "''" + else: + formula_value = fv.value + mappings.append( + { + "field_id": int(fv.field_id), + "value": BaserowFormulaObject.create( + formula_value, mode=BASEROW_FORMULA_MODE_ADVANCED + ), + "enabled": True, + } + ) + return mappings + + +# --------------------------------------------------------------------------- +# Formula dispatch: get_formulas_to_create +# --------------------------------------------------------------------------- + + +def _row_formulas(action: "ActionCreate", orm_action, context) -> dict[str, str]: + """Get formulas for create_row / update_row / delete_row actions.""" + + formulas: dict[str, str] = {} + + # Row ID for update/delete + if action.type in ("update_row", "delete_row") and action.row_id: + if needs_formula(action.row_id): + formulas["row_id"] = formula_desc(action.row_id) + + # Field values for create/update + if action.field_values: + for fv in action.field_values: + if needs_formula(fv.value): + formulas[f"field_{fv.field_id}"] = formula_desc(fv.value) + + return formulas + + +def _open_page_formulas(action: "ActionCreate", orm_action, context) -> dict[str, str]: + """Get formulas for open_page page/query parameters.""" + + formulas: dict[str, str] = {} + for i, p in enumerate(action.page_parameters or []): + if needs_formula(p.value): + formulas[f"page_param_{i}"] = formula_desc(p.value) + for i, p in enumerate(action.query_parameters or []): + if needs_formula(p.value): + formulas[f"query_param_{i}"] = formula_desc(p.value) + return formulas + + +_GET_FORMULAS: dict[str, Any] = { + "create_row": _row_formulas, + "update_row": _row_formulas, + "delete_row": _row_formulas, + "open_page": _open_page_formulas, +} + +# --------------------------------------------------------------------------- +# Formula dispatch: update_action_with_formulas +# --------------------------------------------------------------------------- + + +def _update_row_formulas( + action: "ActionCreate", + orm_action: "BuilderWorkflowAction", + formulas: dict[str, str], +) -> None: + """Apply generated formulas to row-based workflow actions.""" + + if not formulas: + return + + service = orm_action.specific.service.specific + + # Update row_id + if "row_id" in formulas: + service.row_id = BaserowFormulaObject.create( + formulas["row_id"], mode=BASEROW_FORMULA_MODE_ADVANCED + ) + service.save(update_fields=["row_id"]) + + # Update field mappings + for mapping in service.field_mappings.all(): + key = f"field_{mapping.field_id}" + if key in formulas: + mapping.value = BaserowFormulaObject.create( + formulas[key], mode=BASEROW_FORMULA_MODE_ADVANCED + ) + mapping.save(update_fields=["value"]) + + +def _update_open_page_formulas( + action: "ActionCreate", + orm_action: "BuilderWorkflowAction", + formulas: dict[str, str], +) -> None: + """Apply generated formulas to open_page page/query parameters.""" + + if not formulas: + return + + specific = orm_action.specific + page_params = specific.page_parameters or [] + query_params = specific.query_parameters or [] + + for i, p in enumerate(page_params): + key = f"page_param_{i}" + if key in formulas: + p["value"] = BaserowFormulaObject.create( + formulas[key], mode=BASEROW_FORMULA_MODE_ADVANCED + ) + + for i, p in enumerate(query_params): + key = f"query_param_{i}" + if key in formulas: + p["value"] = BaserowFormulaObject.create( + formulas[key], mode=BASEROW_FORMULA_MODE_ADVANCED + ) + + specific.page_parameters = page_params + specific.query_parameters = query_params + specific.save(update_fields=["page_parameters", "query_parameters"]) + + +_UPDATE_FORMULAS: dict[str, Any] = { + "create_row": _update_row_formulas, + "update_row": _update_row_formulas, + "delete_row": _update_row_formulas, + "open_page": _update_open_page_formulas, +} + + +# --------------------------------------------------------------------------- +# ActionCreate (flat) +# --------------------------------------------------------------------------- + + +class ActionCreate(BaseModel): + """ + Flat model for creating a workflow action. + + All type-specific fields are optional — a ``@model_validator`` + enforces the correct required fields per type. + """ + + type: ActionType = Field(..., description="Action type.") + + element: int | str = Field( + ..., + description="Element this action is attached to: int ID (existing) or string ref (same batch).", + ) + event: str = Field( + default="click", + description="Event that triggers the action: click, submit, after_login.", + ) + + # notification + title: str | None = Field(default=None, description="(notification) Title formula.") + description: str | None = Field( + default=None, description="(notification) Message formula." + ) + + # open_page + navigate_to_page_id: int | None = Field( + default=None, description="(open_page) Target page ID." + ) + page_parameters: list[ParameterMapping] | None = Field( + default=None, description="(open_page) Page parameter mappings." + ) + query_parameters: list[ParameterMapping] | None = Field( + default=None, description="(open_page) Query parameter mappings." + ) + target: Literal["self", "blank"] = Field( + default="self", description="(open_page) Navigation target." + ) + + # create_row / update_row / delete_row + table_id: int | None = Field( + default=None, description="(row actions) Target table ID." + ) + row_id: str | None = Field( + default=None, + description="(update_row, delete_row) Row ID formula. Supports $formula: prefix.", + ) + field_values: list[FieldValueMapping] | None = Field( + default=None, + description="(create_row, update_row) Field value mappings. Supports $formula: prefix.", + ) + + # refresh_data_source + data_source: int | str | None = Field( + default=None, + description="(refresh_data_source) Data source: int ID or string ref.", + ) + + @model_validator(mode="after") + def _check_required(self): + for field_name in _REQUIRED_FIELDS.get(self.type, ()): + if getattr(self, field_name) is None: + raise ValueError(f"'{field_name}' is required for type '{self.type}'.") + return self + + # -- ORM helpers -------------------------------------------------------- + + def get_action_type(self) -> str: + """Return the action type string.""" + return self.type + + def get_service_type(self) -> str | None: + """Return the service type string, or None for non-service actions.""" + return _SERVICE_TYPE.get(self.type) + + def to_orm_kwargs(self) -> dict: + """Return non-service kwargs for action creation.""" + fn = _TO_ORM_KWARGS.get(self.type) + return fn(self) if fn else {} + + def to_service_kwargs(self, user, workspace) -> dict | None: + """Return service kwargs for service-based actions.""" + if self.get_service_type() is None: + return None + return _row_service_kwargs(self, user, workspace) + + def get_field_mappings(self) -> list[dict] | None: + """Return field mappings for service-based actions.""" + return _field_mappings(self) + + # -- Formula helpers ---------------------------------------------------- + + def get_formulas_to_create( + self, + orm_action: "BuilderWorkflowAction", + context: "BuilderFormulaContext", + ) -> dict[str, str]: + """Return ``{field_path: description}`` for LLM formula generation.""" + fn = _GET_FORMULAS.get(self.type) + return fn(self, orm_action, context) if fn else {} + + def update_with_formulas( + self, + orm_action: "BuilderWorkflowAction", + formulas: dict[str, str], + ) -> None: + """Apply LLM-generated formulas to this action.""" + fn = _UPDATE_FORMULAS.get(self.type) + if fn: + fn(self, orm_action, formulas) + + +# --------------------------------------------------------------------------- +# ActionItem (for listing) +# --------------------------------------------------------------------------- + + +class FieldMappingItem(BaseModel): + """Field mapping for create_row/update_row workflow actions.""" + + field_id: int + field_name: str + value: str + + +class ActionItem(BaseModel): + """Existing workflow action with ID.""" + + id: int + type: str + element_id: int | None = None + event: str = "click" + table_id: int | None = None + row_id_formula: str | None = None + field_mappings: list[FieldMappingItem] | None = None + + @classmethod + def from_orm(cls, action) -> "ActionItem": + """Create ActionItem from ORM BuilderWorkflowAction instance.""" + + action_type = action.get_type().type + kwargs: dict[str, Any] = { + "id": action.id, + "type": action_type, + "element_id": action.element_id, + "event": action.event, + } + + specific = action.specific + + if action_type in ("create_row", "update_row", "delete_row"): + if hasattr(specific, "service") and specific.service: + service = specific.service.specific + if hasattr(service, "table") and service.table: + kwargs["table_id"] = service.table_id + if hasattr(service, "row_id") and service.row_id: + kwargs["row_id_formula"] = str(service.row_id) + if action_type in ("create_row", "update_row"): + if hasattr(service, "field_mappings"): + mappings = [ + FieldMappingItem( + field_id=m.field_id, + field_name=m.field.name, + value=str(m.value) if m.value else "", + ) + for m in service.field_mappings.all() + ] + if mappings: + kwargs["field_mappings"] = mappings + + return cls(**kwargs) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/core/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/core/tools.py index f8dc64e0e2..4a3a68bf6c 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/core/tools.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/core/tools.py @@ -11,7 +11,7 @@ from baserow.core.service import CoreService from baserow_enterprise.assistant.deps import AgentMode, AssistantDeps -from .types import BuilderItem, BuilderItemCreate, builder_type_registry +from .types import BuilderItem, BuilderItemCreate, BuilderUpdate, builder_type_registry def list_builders( @@ -169,5 +169,43 @@ def switch_mode( return f"Switched to {target.value} mode." -TOOL_FUNCTIONS = [list_builders, create_builders, switch_mode] +def update_builder( + ctx: RunContext[AssistantDeps], + update: Annotated[ + BuilderUpdate, Field(description="Application settings to update.") + ], + thought: Annotated[ + str, Field(description="Brief reasoning for calling this tool.") + ], +) -> dict[str, Any]: + """\ + Update an application's settings (name, login page, etc.). + + WHEN to use: User wants to rename an application, set a login page, or change application-level settings. + WHAT it does: Updates the specified application's settings. Fields are type-specific. + RETURNS: Updated application info. + HOW: For setting a login page on a builder app, use setup_user_source first (which creates the login page), then call this if you need to change it. + """ + + from baserow.core.handler import CoreHandler + + user = ctx.deps.user + + app = CoreService().get_application(user, update.builder_id).specific + ctx.deps.tool_helpers.update_status( + _("Updating %(app_name)s...") % {"app_name": app.name} + ) + + update_kwargs = update.to_update_kwargs(app) + if update_kwargs: + CoreHandler().update_application(user, app, **update_kwargs) + app.refresh_from_db() + + result: dict[str, Any] = {"id": app.id, "name": app.name} + if hasattr(app, "login_page_id"): + result["login_page_id"] = app.login_page_id + return result + + +TOOL_FUNCTIONS = [list_builders, create_builders, update_builder, switch_mode] core_toolset = FunctionToolset(TOOL_FUNCTIONS, max_retries=3) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/core/types.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/core/types.py index 0612620349..c3a58ea951 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/core/types.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/core/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, Literal +from typing import Annotated, Literal, Type from pydantic import Field @@ -174,3 +174,35 @@ def from_django_orm(self, orm_app: BaserowApplication) -> BuilderItem: builder_type_registry = BuilderItemRegistry() + + +class BuilderUpdate(BaseModel): + """ + Update an existing application's settings. + + Fields are type-specific — only set the ones relevant to the application type. + """ + + builder_id: int = Field(..., description="ID of the application to update.") + name: str | None = Field(default=None, description="New name.") + + # Application (builder) specific + login_page_id: int | None = Field( + default=None, + description="(application) ID of the page to use as the login page.", + ) + + def to_update_kwargs(self, app: Type[BaserowApplication]) -> dict: + """Return kwargs for ``CoreHandler().update_application()``.""" + + app_type = application_type_registry.get_by_model(app.specific_class).type + + kwargs: dict = {} + if self.name is not None: + kwargs["name"] = self.name + + match app_type: + case "builder" | "application": + if self.login_page_id is not None: + kwargs["login_page_id"] = self.login_page_id + return kwargs diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/helpers.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/helpers.py index 1652f2a207..1ff487349d 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/helpers.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/helpers.py @@ -41,10 +41,6 @@ from baserow_enterprise.assistant.deps import ToolHelpers -class ToolInputError(Exception): - """Raised when tool input is invalid — returned to the model as an error message.""" - - def filter_tables(user: AbstractUser, workspace: Workspace) -> QuerySet[Table]: """Return all tables visible to the user in the given workspace.""" @@ -54,6 +50,8 @@ def filter_tables(user: AbstractUser, workspace: Workspace) -> QuerySet[Table]: def get_table(user: AbstractUser, workspace: Workspace, table_id: int) -> Table: """Get a single table by ID, raising ToolInputError if not found.""" + from baserow_enterprise.assistant.tools.builder.helpers import ToolInputError + try: return filter_tables(user, workspace).get(id=table_id) except Table.DoesNotExist: diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py index 08f221d1cf..92f4c960e8 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py @@ -1222,9 +1222,9 @@ def load_row_tools( database_toolset = FunctionToolset(TOOL_FUNCTIONS, max_retries=3) ROUTING_RULES = """\ -- Check list_* before create_* to avoid duplicates. - switch_mode: switch domain if task needs tools not in the current mode. - Database row CRUD → call load_row_tools first (includes schema — skip get_tables_schema). - create_tables: include ALL related tables in one call so link_row fields connect properly. Add sample rows unless told otherwise. - create_rows: fill EVERY field including ALL link_row fields. -- After creating tables for an app/automation task, switch_mode back to continue building.""" +- When creating views/filters for a builder data source, complete ALL view + filter creation before switching back to application mode. Workflow: create_views → create_view_filters → then switch_mode("application"). +- After creating tables or views for an application/data source/automation task, switch_mode back to continue building.""" diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/views.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/views.py index 72d9efd74b..fdbf2d7fdd 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/views.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/views.py @@ -162,7 +162,7 @@ def _form_field_options_from_orm(orm_view): _FROM_DJANGO_ORM: dict[str, Any] = { - "grid": lambda v: {"row_height": "small"}, + "grid": lambda v: {"row_height": v.row_height_size}, "kanban": lambda v: {"column_field_id": v.single_select_field_id}, "calendar": lambda v: {"date_field_id": v.date_field_id}, "gallery": lambda v: {"cover_field_id": v.card_cover_image_field_id}, @@ -187,7 +187,7 @@ class ViewItemCreate(BaseModel): """Flat model for creating a view: name + type + type-specific options.""" name: str = Field(..., description="Descriptive view name.") - public: bool = Field(..., description="Publicly accessible? Default false.") + public: bool = Field(False, description="Publicly accessible? Default false.") type: ViewType = Field(..., description="View type.") # -- grid -- @@ -246,7 +246,7 @@ class ViewItemCreate(BaseModel): def _validate_required_for_type(self): required = self._REQUIRED_FIELDS.get(self.type) if required: - missing = [name for attr, name in required if not getattr(self, attr)] + missing = [name for attr, name in required if getattr(self, attr) is None] if missing: raise ValueError( f"{self.type} requires {', '.join(missing)}. " diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/toolset.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/toolset.py index f4e4497a4c..d424f1bef1 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/toolset.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/toolset.py @@ -279,16 +279,12 @@ def _build_mode_tool_map() -> dict[AgentMode, frozenset[str]]: """ from .automation.tools import TOOL_FUNCTIONS as AUTO_FN - from .core.tools import create_builders, list_builders, switch_mode + from .builder.tools import TOOL_FUNCTIONS as BUILDER_FN + from .core.tools import create_builders, list_builders, switch_mode, update_builder from .database.tools import TOOL_FUNCTIONS as DB_FN from .navigation.tools import navigate from .search_user_docs.tools import search_user_docs - try: - from .builder.tools import TOOL_FUNCTIONS as BUILDER_FN - except ImportError: - BUILDER_FN = [] - n = frozenset # alias for readability def names(*funcs): @@ -303,9 +299,10 @@ def names(*funcs): ) return { - AgentMode.DATABASE: shared | names(*DB_FN, create_builders), - AgentMode.APPLICATION: shared | names(*BUILDER_FN, create_builders), - AgentMode.AUTOMATION: shared | names(*AUTO_FN, create_builders), + AgentMode.DATABASE: shared | names(*DB_FN, create_builders, update_builder), + AgentMode.APPLICATION: shared + | names(*BUILDER_FN, create_builders, update_builder), + AgentMode.AUTOMATION: shared | names(*AUTO_FN, create_builders, update_builder), AgentMode.EXPLAIN: shared | names( *[f for f in BUILDER_FN if f.__name__.startswith("list_")], @@ -371,7 +368,7 @@ async def call_tool( tool: ToolsetTool[AgentDepsT], ) -> Any: from baserow.core.exceptions import UserNotInWorkspace - from baserow_enterprise.assistant.tools.database.helpers import ToolInputError + from baserow_enterprise.assistant.tools.builder.helpers import ToolInputError try: return await self._inner.call_tool(name, tool_args, ctx, tool) diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder.py new file mode 100644 index 0000000000..9e4bf0bdc9 --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder.py @@ -0,0 +1,1198 @@ +import re + +import pytest + +from baserow.contrib.builder.data_sources.models import DataSource +from baserow.contrib.builder.elements.models import ( + Element, + MenuItemElement, +) +from baserow.contrib.builder.pages.models import Page +from baserow.contrib.builder.workflow_actions.models import BuilderWorkflowAction +from baserow_enterprise.assistant.types import ( + ApplicationUIContext, + UIContext, + UserUIContext, + WorkspaceUIContext, +) + +from .eval_utils import ( + EvalChecklist, + assert_tool_call_order, + count_tool_errors, + create_eval_assistant, + format_message_history, + print_message_history, +) + +# --------------------------------------------------------------------------- +# UI context helper +# --------------------------------------------------------------------------- + + +def build_builder_ui_context(user, workspace, builder, page=None) -> str: + ctx = UIContext( + workspace=WorkspaceUIContext(id=workspace.id, name=workspace.name), + application=ApplicationUIContext(id=str(builder.id), name=builder.name), + user=UserUIContext(id=user.id, name=user.first_name, email=user.email), + ) + return ctx.format() + + +# --------------------------------------------------------------------------- +# Prompts — one per test, all at the top for easy coverage scanning +# --------------------------------------------------------------------------- + +PROMPT_LIST_PAGES = "List all pages in builder '{builder_name}'." + +PROMPT_CREATE_LANDING_PAGE = ( + "In builder '{builder_name}', create a page called " + "'Home' at path '/'. Add a heading saying 'Welcome' and a text element " + "saying 'This is our landing page'. Also add a button labeled 'Get Started' " + "that links to '/contact'." +) + +PROMPT_CREATE_CONTACT_FORM = ( + "In builder '{builder_name}', create a page called " + "'Contact' at path '/contact'. Add a form container with text inputs " + "for Name and Email, and a submit button. " + "Add a create_row action on the form's submit event that creates a row " + "in table '{table_name}' mapping the Name and the Email." +) + +PROMPT_CREATE_DATA_SOURCE_PAGE = ( + "In builder '{builder_name}', create a page called " + "'Products' at path '/products'. Add a list_rows data source called " + "'All Products' that reads from table '{table_name}'. " + "Then add a repeat element using that data source and inside it " + "a heading element." +) + +PROMPT_SHARED_HEADER_WITH_MENU = ( + "In builder '{builder_name}', add a shared header with " + "a menu that links to all three pages: Home, About, " + "and Contact." +) + +PROMPT_BACK_BUTTON_ON_DETAIL = ( + "In builder '{builder_name}', add a 'Back to List' button " + "on the Detail page that navigates to the List page." +) + +PROMPT_BACK_LINK_ON_DETAIL = ( + "In builder '{builder_name}', add a 'Back to list' link " + "on the Detail page that goes to the List page." +) + +PROMPT_TABLE_WITH_EDIT_BUTTON = ( + "In builder '{builder_name}', create two pages: " + "a 'List' page at '/list' and an 'Edit' page at '/edit/:id'. " + "On the List page, add a list_rows data source for table '{table_name}', " + "then add a table element showing columns for {field_names}. " + "Add an Edit button that links to the Edit page, passing the row id." +) + +PROMPT_CREATE_LANDING_PAGE_WITH_EXISTING = ( + "Create a landing page with a heading, description, " + "and CTA button for my {builder_name}" +) + +PROMPT_FILTERED_DATA_SOURCE = ( + "In builder '{builder_name}', create a page called 'Pending Tasks' at " + "'/pending'. Show only tasks where Status is 'Pending' from the " + "'{table_name}' table in a table element with columns for Name and Status." +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_agent( + agent, deps, tracker, model, usage_limits, toolset, question, ui_context +): + deps.tool_helpers.request_context["ui_context"] = ui_context + + from baserow_enterprise.assistant.deps import AgentMode + + ctx = UIContext.model_validate_json(ui_context) + if ctx.application or ctx.page: + deps.mode = AgentMode.APPLICATION + elif ctx.automation or ctx.workflow: + deps.mode = AgentMode.AUTOMATION + else: + deps.mode = AgentMode.DATABASE + + return agent.run_sync( + user_prompt=question, + deps=deps, + model=model, + usage_limits=usage_limits, + toolsets=[toolset], + ) + + +def _filter_tool_calls(result, tool_names=None): + """Return assistant-side tool call entries, optionally filtered by name(s).""" + history = format_message_history(result) + calls = [e for e in history if e["role"] == "assistant" and "args" in e] + if tool_names is None: + return calls + if isinstance(tool_names, str): + tool_names = {tool_names} + else: + tool_names = set(tool_names) + return [e for e in calls if e.get("tool_name") in tool_names] + + +_ELEMENT_CREATION_TOOLS = { + "create_display_elements", + "create_layout_elements", + "create_form_elements", + "create_collection_elements", +} + + +def _collect_element_args(result, tool_names=None): + """Flatten all element dicts from element-creation tool calls.""" + tools = tool_names or _ELEMENT_CREATION_TOOLS + calls = _filter_tool_calls(result, tools) + elements = [] + for call in calls: + elements.extend(call["args"].get("elements", [])) + return elements + + +# --------------------------------------------------------------------------- +# Evals +# --------------------------------------------------------------------------- + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_lists_pages(data_fixture, eval_model): + """Agent should call list_pages when asked about builder pages.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="My App" + ) + data_fixture.create_builder_page(builder=builder, name="Home", path="/") + data_fixture.create_builder_page(builder=builder, name="About", path="/about") + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=10, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_LIST_PAGES.format(builder_name=builder.name), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + history = format_message_history(result) + list_page_calls = _filter_tool_calls(result, "list_pages") + + with EvalChecklist("lists pages") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "called list_pages", + len(list_page_calls) >= 1, + hint=f"tools called: {[e.get('tool_name') for e in history if e.get('tool_name')]}", + ) + checks.check( + "response mentions 'Home'", + "Home" in result.output, + hint=f"output: {result.output[:300]}", + ) + checks.check( + "response mentions 'About'", + "About" in result.output, + hint=f"output: {result.output[:300]}", + ) + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_creates_landing_page(data_fixture, eval_model): + """Agent should create a page with heading, text, and button elements.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="Website" + ) + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=20, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_CREATE_LANDING_PAGE.format(builder_name=builder.name), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + # Pages must be created before elements — only enforce when no errors, because + # a failed early call (retry) would make first_B appear before last_A even though + # the model ultimately did it right. The EvalChecklist "no tool errors" check + # captures the retry case. + if err_count == 0: + assert_tool_call_order(result, ["create_pages", "create_display_elements"]) + + pages = Page.objects.filter(builder=builder, shared=False) + page = pages.first() + elements = Element.objects.filter(page=page) if page else Element.objects.none() + + all_el_args = _collect_element_args(result) + heading_args = [e for e in all_el_args if e.get("type") == "heading"] + button_args = [e for e in all_el_args if e.get("type") == "button"] + heading_texts = [str(e.get("value", "")).lower() for e in heading_args] + button_texts = [ + str(e.get("value", "") or e.get("label", "")).lower() for e in button_args + ] + + with EvalChecklist("creates landing page") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check("page created", pages.exists(), hint="no pages found in DB") + checks.check( + "page name is 'Home'", + page is not None and "home" in page.name.lower(), + hint=f"page name: {page.name if page else None}", + ) + checks.check( + "page path is '/'", + page is not None and page.path == "/", + hint=f"page path: {page.path if page else None}", + ) + checks.check( + ">=3 elements (heading, text, button)", + elements.count() >= 3, + hint=f"got {elements.count()} elements", + ) + checks.check( + "heading element with 'Welcome'", + any("welcome" in t for t in heading_texts), + hint=f"heading texts from args: {heading_texts}", + ) + checks.check( + "button labeled 'Get Started'", + any("get started" in t for t in button_texts), + hint=f"button texts from args: {button_texts}", + ) + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_creates_contact_form(data_fixture, eval_model): + """Agent should create a contact form page with form inputs and a + create_row action on submit.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="Contact App" + ) + database = data_fixture.create_database_application( + user=user, workspace=workspace, name="CRM" + ) + table = data_fixture.create_database_table( + user=user, database=database, name="Contacts" + ) + name_field = data_fixture.create_text_field(table=table, name="Name", primary=True) + email_field = data_fixture.create_email_field(table=table, name="Email") + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=25, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_CREATE_CONTACT_FORM.format( + builder_name=builder.name, + table_name=table.name, + table_id=table.id, + name_field_id=name_field.id, + email_field_id=email_field.id, + ), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + # Pages → form elements → actions + assert_tool_call_order(result, ["setup_page"]) + + pages = Page.objects.filter(builder=builder, shared=False) + page = pages.first() + elements = Element.objects.filter(page=page) if page else Element.objects.none() + + actions = ( + BuilderWorkflowAction.objects.filter(page=page) + if page + else BuilderWorkflowAction.objects.none() + ) + create_row_action = actions.filter( + content_type__model="localbaserowcreaterowworkflowaction" + ).first() + + # Field mappings + service = None + mappings = {} + if create_row_action is not None: + service = create_row_action.specific.service.specific + mappings = { + m.field_id: m.value for m in service.field_mappings.filter(enabled=True) + } + + form_input_ids = set( + elements.filter( + content_type__model__in=["inputtextelement", "inputemailelement"] + ).values_list("id", flat=True) + ) + + form_data_re = re.compile(r"form_data\.(\d+)") + all_map_formulas_ok = ( + all( + bool({int(m) for m in form_data_re.findall(str(formula))} & form_input_ids) + for formula in mappings.values() + ) + if mappings and form_input_ids + else False + ) + + with EvalChecklist("creates contact form") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check("page created", pages.exists(), hint="no pages found in DB") + checks.check( + "page name is 'Contact'", + page is not None and "contact" in page.name.lower(), + hint=f"page name: {page.name if page else None}", + ) + checks.check( + "page path is '/contact'", + page is not None and page.path == "/contact", + hint=f"page path: {page.path if page else None}", + ) + checks.check( + ">=3 elements (form container + inputs)", + elements.count() >= 3, + hint=f"got {elements.count()} elements", + ) + checks.check( + "create_row workflow action exists", + create_row_action is not None, + hint=f"action types: {list(actions.values_list('content_type__model', flat=True))}", + ) + checks.check( + "create_row targets Contacts table", + service is not None and service.table_id == table.id, + hint=f"service table_id={service.table_id if service else None}, expected={table.id}", + ) + checks.check( + "Name field is mapped", + name_field.id in mappings, + hint=f"mapped field IDs: {set(mappings)}", + ) + checks.check( + "Email field is mapped", + email_field.id in mappings, + hint=f"mapped field IDs: {set(mappings)}", + ) + checks.check( + "all field mappings reference form input elements", + all_map_formulas_ok, + hint=f"formulas: {list(mappings.values())}, form input IDs: {form_input_ids}", + ) + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_creates_data_source_with_repeat(data_fixture, eval_model): + """Agent should create a page with a data source and a repeat element.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="Product Catalog" + ) + database = data_fixture.create_database_application( + user=user, workspace=workspace, name="Store" + ) + table = data_fixture.create_database_table( + user=user, database=database, name="Products" + ) + data_fixture.create_text_field(table=table, name="Name", primary=True) + data_fixture.create_number_field(table=table, name="Price") + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=25, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_CREATE_DATA_SOURCE_PAGE.format( + builder_name=builder.name, + table_name=table.name, + table_id=table.id, + ), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + # Pages must be created before data setup. Accept either the low-level path + # (create_data_sources + create_collection_elements) or the high-level + # setup_page which handles both in one call. + if _filter_tool_calls(result, "setup_page"): + assert_tool_call_order(result, ["create_pages", "setup_page"]) + else: + assert_tool_call_order( + result, + ["create_pages", "create_data_sources", "create_collection_elements"], + ) + + pages = Page.objects.filter(builder=builder, shared=False) + page = pages.first() + + # Data source args — from create_data_sources or setup_page (both are valid) + ds_calls = _filter_tool_calls(result, "create_data_sources") + setup_calls = _filter_tool_calls(result, "setup_page") + if ds_calls: + data_sources = ds_calls[0]["args"].get("data_sources", []) + elif setup_calls: + data_sources = setup_calls[0]["args"].get("data_sources", []) or [] + else: + data_sources = [] + first_ds = data_sources[0] if data_sources else {} + ds_name = first_ds.get("name", "") + ds_table_id = first_ds.get("table_id") + ds_type = first_ds.get("type") + + # Element args — from individual tools or setup_page + all_el_args = _collect_element_args(result) + for call in setup_calls: + all_el_args.extend(call["args"].get("elements", []) or []) + repeat_elements = [e for e in all_el_args if e.get("type") == "repeat"] + + with EvalChecklist("creates data source with repeat") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check("page created", pages.exists(), hint="no pages found in DB") + checks.check( + "page name is 'Products'", + page is not None and "product" in page.name.lower(), + hint=f"page name: {page.name if page else None}", + ) + checks.check( + "page path is '/products'", + page is not None and page.path == "/products", + hint=f"page path: {page.path if page else None}", + ) + checks.check( + "data source created", + len(data_sources) >= 1, + hint=f"ds_calls: {len(ds_calls)}, setup_calls: {len(setup_calls)}", + ) + checks.check( + "data source type is list_rows", + ds_type == "list_rows", + hint=f"got type: {ds_type}", + ) + checks.check( + "data source named 'All Products'", + "all products" in ds_name.lower(), + hint=f"got name: '{ds_name}'", + ) + checks.check( + "data source table_id matches Products table", + ds_table_id == table.id, + hint=f"got table_id={ds_table_id}, expected={table.id}", + ) + checks.check( + "repeat element in args", + len(repeat_elements) >= 1, + hint=f"element types: {[e.get('type') for e in all_el_args]}", + ) + + +# --------------------------------------------------------------------------- +# Shared element evals +# --------------------------------------------------------------------------- + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_creates_header_with_menu(data_fixture, eval_model): + """Agent should create a header on the shared page with a menu linking to pages.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="Nav App" + ) + home = data_fixture.create_builder_page(builder=builder, name="Home", path="/") + about = data_fixture.create_builder_page( + builder=builder, name="About", path="/about" + ) + contact = data_fixture.create_builder_page( + builder=builder, name="Contact", path="/contact" + ) + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=25, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_SHARED_HEADER_WITH_MENU.format( + builder_name=builder.name, + ), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + # Layout (header) must be created before display elements (menu) + assert_tool_call_order(result, ["create_layout_elements"]) + + shared_page = builder.shared_page + shared_elements = Element.objects.filter(page=shared_page) + header_elements = shared_elements.filter(content_type__model="headerelement") + menu_elements = shared_elements.filter(content_type__model="menuelement") + + menu_element = menu_elements.first().specific if menu_elements.exists() else None + menu_items = ( + MenuItemElement.objects.filter( + pk__in=menu_element.menu_items.values_list("pk", flat=True) + ).select_related("navigate_to_page") + if menu_element is not None + else MenuItemElement.objects.none() + ) + linked_page_ids = { + item.navigate_to_page_id + for item in menu_items + if item.navigate_to_page_id is not None + } + expected_page_ids = {home.id, about.id, contact.id} + + with EvalChecklist("creates header with menu") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "header element on shared page", + header_elements.exists(), + hint=f"shared page elements: {list(shared_elements.values_list('content_type__model', flat=True))}", + ) + checks.check( + "menu element on shared page", + menu_elements.exists(), + hint="expected a menu element inside the header on the shared page", + ) + checks.check( + ">=3 menu items (Home, About, Contact)", + menu_items.count() >= 3, + hint=f"got {menu_items.count()} menu items", + ) + checks.check( + "menu links to Home page", + home.id in linked_page_ids, + hint=f"linked page IDs: {linked_page_ids}, expected Home={home.id}", + ) + checks.check( + "menu links to About page", + about.id in linked_page_ids, + hint=f"linked page IDs: {linked_page_ids}, expected About={about.id}", + ) + checks.check( + "menu links to Contact page", + contact.id in linked_page_ids, + hint=f"linked page IDs: {linked_page_ids}, expected Contact={contact.id}", + ) + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_puts_back_button_on_page_not_header(data_fixture, eval_model): + """Agent should place a back button on the page itself, not in the shared header.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="App" + ) + list_page = data_fixture.create_builder_page( + builder=builder, name="List", path="/list" + ) + detail_page = data_fixture.create_builder_page( + builder=builder, name="Detail", path="/detail" + ) + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=25, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_BACK_BUTTON_ON_DETAIL.format( + builder_name=builder.name, + ), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + detail_elements = Element.objects.filter(page=detail_page) + shared_page = builder.shared_page + shared_elements = Element.objects.filter(page=shared_page) + + button_args = [ + e for e in _collect_element_args(result) if e.get("type") == "button" + ] + button_texts = [ + str(e.get("value", "") or e.get("label", "")).lower() for e in button_args + ] + + with EvalChecklist("back button on page not header") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "called create_display_elements", + len(_filter_tool_calls(result, "create_display_elements")) >= 1, + hint=f"tools: {[e.get('tool_name') for e in format_message_history(result) if e.get('tool_name')]}", + ) + checks.check( + "elements exist on Detail page", + detail_elements.exists(), + hint="no elements on Detail page", + ) + checks.check( + "button labeled 'Back to List'", + any("back" in t for t in button_texts), + hint=f"button texts: {button_texts}", + ) + checks.check( + "no elements added to shared page", + not shared_elements.exists(), + hint=f"shared page has: {list(shared_elements.values_list('content_type__model', flat=True))}", + ) + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_creates_page_specific_nav_on_page(data_fixture, eval_model): + """Agent should create a 'Back to list' link on the page, not shared header.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="App" + ) + list_page = data_fixture.create_builder_page( + builder=builder, name="List", path="/list" + ) + detail_page = data_fixture.create_builder_page( + builder=builder, name="Detail", path="/detail" + ) + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=25, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_BACK_LINK_ON_DETAIL.format( + builder_name=builder.name, + ), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + detail_elements = Element.objects.filter(page=detail_page) + shared_page = builder.shared_page + shared_elements = Element.objects.filter(page=shared_page) + + link_elements = detail_elements.filter(content_type__model="linkelement") + button_elements = detail_elements.filter(content_type__model="buttonelement") + menu_elements = detail_elements.filter(content_type__model="menuelement") + + # Navigation target checks for link element + link_targets_list = False + if link_elements.exists(): + link_el = link_elements.first().specific + link_targets_list = ( + link_el.navigate_to_page_id == list_page.id + or "/list" in str(link_el.navigate_to_url) + ) + + # Navigation target checks for menu element + menu_links_list = False + if menu_elements.exists(): + menu_element = menu_elements.first().specific + menu_items = MenuItemElement.objects.filter( + pk__in=menu_element.menu_items.values_list("pk", flat=True) + ) + linked_ids = { + item.navigate_to_page_id + for item in menu_items + if item.navigate_to_page_id is not None + } + menu_links_list = list_page.id in linked_ids + + has_nav_element = ( + link_elements.exists() or button_elements.exists() or menu_elements.exists() + ) + nav_targets_list_page = link_targets_list or menu_links_list + + with EvalChecklist("page-specific nav on page not header") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "called create_display_elements", + len(_filter_tool_calls(result, "create_display_elements")) >= 1, + hint=f"tools: {[e.get('tool_name') for e in format_message_history(result) if e.get('tool_name')]}", + ) + checks.check( + "elements exist on Detail page", + detail_elements.exists(), + hint="no elements on Detail page", + ) + checks.check( + "link/button/menu element on Detail page", + has_nav_element, + hint=f"detail page elements: {list(detail_elements.values_list('content_type__model', flat=True))}", + ) + checks.check( + "nav element targets List page", + nav_targets_list_page, + hint=f"link_targets_list={link_targets_list}, menu_links_list={menu_links_list}", + ) + checks.check( + "no elements added to shared page", + not shared_elements.exists(), + hint=f"shared page has: {list(shared_elements.values_list('content_type__model', flat=True))}", + ) + + +# --------------------------------------------------------------------------- +# Table element with edit button eval +# --------------------------------------------------------------------------- + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_creates_table_with_edit_button(data_fixture, eval_model): + """Agent should create a list page with a table element showing columns + and an edit button that navigates to the edit page with the row id.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="Product App" + ) + database = data_fixture.create_database_application( + user=user, workspace=workspace, name="Store" + ) + table = data_fixture.create_database_table( + user=user, database=database, name="Products" + ) + name_field = data_fixture.create_text_field(table=table, name="Name", primary=True) + price_field = data_fixture.create_number_field(table=table, name="Price") + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=30, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_TABLE_WITH_EDIT_BUTTON.format( + builder_name=builder.name, + table_name=table.name, + field_names="Name and Price", + ), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + pages = Page.objects.filter(builder=builder, shared=False) + list_page = pages.filter(name__icontains="List").first() + edit_page = pages.filter(name__icontains="Edit").first() + + list_elements = ( + Element.objects.filter(page=list_page) if list_page else Element.objects.none() + ) + table_elements = list_elements.filter(content_type__model="tableelement") + table_el = table_elements.first().specific if table_elements.exists() else None + + columns = table_el.fields.all().order_by("order") if table_el else [] + col_count = len(list(columns)) if table_el else 0 + + # Check data columns reference correct fields + field_id_re = re.compile(r"field_(\d+)") + referenced_field_ids = set() + link_columns = [] + if table_el: + for col in columns: + formula = str(getattr(col, "config", "") or "") + referenced_field_ids.update(int(m) for m in field_id_re.findall(formula)) + if getattr(col, "type", None) in ("link", "button"): + link_columns.append(col) + + name_col_ok = name_field.id in referenced_field_ids or any( + "Name" in (getattr(col, "name", "") or "") + for col in (columns if table_el else []) + ) + + # Edit button workflow action + action = None + if link_columns: + link_col = link_columns[0] + action = BuilderWorkflowAction.objects.filter( + page=list_page, event=f"{link_col.uid}_click", element=table_el + ).first() + + with EvalChecklist("creates table with edit button") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "called setup_page or create_pages", + len(_filter_tool_calls(result, ["setup_page", "create_pages"])) >= 1, + hint=f"tools: {[e.get('tool_name') for e in format_message_history(result) if e.get('tool_name')]}", + ) + checks.check( + "List page created", + list_page is not None, + hint=f"pages: {list(pages.values_list('name', flat=True))}", + ) + checks.check( + "List page path is '/list'", + list_page is not None and list_page.path == "/list", + hint=f"list page path: {list_page.path if list_page else None}", + ) + checks.check( + "Edit page created", + edit_page is not None, + hint=f"pages: {list(pages.values_list('name', flat=True))}", + ) + checks.check( + "Edit page path contains '/edit'", + edit_page is not None and "/edit" in edit_page.path, + hint=f"edit page path: {edit_page.path if edit_page else None}", + ) + checks.check( + "table element on List page", + table_elements.exists(), + hint=f"list page elements: {list(list_elements.values_list('content_type__model', flat=True))}", + ) + checks.check( + ">=2 columns (Name, Price)", + col_count >= 2, + hint=f"got {col_count} columns", + ) + checks.check( + "Name field referenced in column config", + name_col_ok, + hint=f"referenced field IDs: {referenced_field_ids}, name_field.id={name_field.id}", + ) + checks.check( + "link/button column for 'Edit'", + len(link_columns) >= 1, + hint=f"column types: {[getattr(c, 'type', None) for c in columns]}", + ) + checks.check( + "edit button column is type 'button'", + any(getattr(c, "type", None) == "button" for c in link_columns), + hint=f"link column types: {[getattr(c, 'type', None) for c in link_columns]}", + ) + checks.check( + "edit button action navigates to Edit page", + action is not None and action.specific.navigate_to_page_id == edit_page.id, + hint=( + f"action={action}, navigate_to_page_id=" + f"{action.specific.navigate_to_page_id if action else None}, " + f"expected={edit_page.id if edit_page else None}" + ), + ) + + +# --------------------------------------------------------------------------- +# Filtered data source via view eval +# --------------------------------------------------------------------------- + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_creates_filtered_data_source_via_view(data_fixture, eval_model): + """Agent should switch to database mode to create a filtered view, then + switch back to application mode to create a data source referencing it. + + Scenario: Tasks table with a Status single_select field. User wants a page + showing only 'Pending' tasks. The agent should: + 1. switch_mode("database") + 2. create_views (grid view for the filter) + 3. create_view_filters (Status = Pending) + 4. switch_mode("application") + 5. create a data source with the view_id + """ + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + database = data_fixture.create_database_application( + user=user, workspace=workspace, name="Project DB" + ) + table = data_fixture.create_database_table( + user=user, database=database, name="Tasks" + ) + data_fixture.create_text_field(table=table, name="Name", primary=True) + status_field = data_fixture.create_single_select_field(table=table, name="Status") + data_fixture.create_select_option( + field=status_field, value="Pending", color="light-orange" + ) + data_fixture.create_select_option( + field=status_field, value="Done", color="light-green" + ) + + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="Task App" + ) + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=30, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_FILTERED_DATA_SOURCE.format( + builder_name=builder.name, + table_name=table.name, + ), + ui_context=ui_context, + ) + + from baserow.contrib.database.views.models import View, ViewFilter + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + # Check tool call sequence + switch_mode_calls = _filter_tool_calls(result, "switch_mode") + + switched_to_db = any(c["args"].get("mode") == "database" for c in switch_mode_calls) + switched_back_to_app = any( + c["args"].get("mode") == "application" for c in switch_mode_calls + ) + + # Verify DB state: view + filter created on the Tasks table + views = View.objects.filter(table=table) + view_filters = ViewFilter.objects.filter(view__table=table, field=status_field) + + # Verify DB state: data source service has a view FK set + pages = Page.objects.filter(builder=builder, shared=False) + data_sources = DataSource.objects.filter(page__builder=builder, page__shared=False) + ds_view_ids = [] + for ds in data_sources: + service = ds.service.specific if ds.service else None + if service and hasattr(service, "view_id") and service.view_id: + ds_view_ids.append(service.view_id) + + with EvalChecklist("filtered data source via view") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "switched to database mode", + switched_to_db, + hint=f"switch_mode calls: {[c['args'] for c in switch_mode_calls]}", + ) + checks.check( + "view created on Tasks table", + views.exists(), + hint=f"views for table: {list(views.values_list('name', flat=True))}", + ) + checks.check( + "view filter on Status field", + view_filters.exists(), + hint=f"view_filters: {list(view_filters.values_list('field__name', 'value'))}", + ) + checks.check( + "switched back to application mode", + switched_back_to_app, + hint=f"switch_mode calls: {[c['args'] for c in switch_mode_calls]}", + ) + checks.check( + "page created", + pages.exists(), + hint=f"pages: {list(pages.values_list('name', flat=True))}", + ) + checks.check( + "data source in DB has view set", + len(ds_view_ids) >= 1, + hint=f"data source view_ids in DB: {ds_view_ids}", + ) + + +# --------------------------------------------------------------------------- +# New page vs modifying existing page eval +# --------------------------------------------------------------------------- + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_creates_new_page_not_modifies_existing(data_fixture, eval_model): + """Agent should create a NEW landing page, not add elements to an existing page. + + Scenario: Builder already has a Home page with some content. User asks to + "create a landing page". The agent should create a new page rather than + modifying the existing Home page. + """ + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="Back to Local" + ) + home_page = data_fixture.create_builder_page(builder=builder, name="Home", path="/") + # Pre-populate with existing content so the agent sees it's not empty + data_fixture.create_builder_heading_element(page=home_page, value="'Welcome Home'") + data_fixture.create_builder_text_element( + page=home_page, value="'Existing content on the home page.'" + ) + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=25, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_CREATE_LANDING_PAGE_WITH_EXISTING.format( + builder_name=builder.name, + ), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + # Check that a new page was created (not just the existing Home) + pages = Page.objects.filter(builder=builder, shared=False) + new_pages = pages.exclude(id=home_page.id) + + # Check elements were added to the NEW page, not the existing Home + home_elements_after = Element.objects.filter(page=home_page) + new_page_elements = ( + Element.objects.filter(page=new_pages.first()) + if new_pages.exists() + else Element.objects.none() + ) + + # The home page started with 2 elements — if more were added, the agent + # modified it instead of creating a new page + home_element_count_before = 2 + home_was_modified = home_elements_after.count() > home_element_count_before + + # Check create_pages was called (not just setup_page on existing page) + create_page_calls = _filter_tool_calls(result, "create_pages") + setup_page_calls = _filter_tool_calls(result, "setup_page") + + # If setup_page was called, check it targeted a new page, not home_page + setup_targeted_home = any( + c["args"].get("page_id") == home_page.id for c in setup_page_calls + ) + + with EvalChecklist("creates new page not modifies existing") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "called create_pages", + len(create_page_calls) >= 1, + hint=f"tools: {[e.get('tool_name') for e in format_message_history(result) if e.get('tool_name')]}", + ) + checks.check( + "new page exists in DB", + new_pages.exists(), + hint=f"all pages: {list(pages.values_list('name', flat=True))}", + ) + checks.check( + "new page has elements", + new_page_elements.count() >= 2, + hint=f"new page elements: {new_page_elements.count()}", + ) + checks.check( + "home page was NOT modified", + not home_was_modified, + hint=f"home page elements: {home_elements_after.count()} (started with {home_element_count_before})", + ) + checks.check( + "setup_page did NOT target existing Home page", + not setup_targeted_home, + hint=f"setup_page page_ids: {[c['args'].get('page_id') for c in setup_page_calls]}", + ) diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder_proactive.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder_proactive.py new file mode 100644 index 0000000000..efddf4e89a --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder_proactive.py @@ -0,0 +1,302 @@ +""" +Eval: the agent should ask the user when implied resources don't exist. + +When the user says "create an app showing projects", the agent should look for +a "projects" table, and if none exists, ask the user which table to use rather +than creating a new table and building everything on top of it. + +When a matching table IS found the agent should proceed to build the app. + +Run with: pytest -m eval -k test_eval_builder_proactive -v -s +""" + +import pytest + +from baserow.contrib.builder.pages.models import Page +from baserow_enterprise.assistant.deps import AgentMode +from baserow_enterprise.assistant.types import ( + ApplicationUIContext, + UIContext, + UserUIContext, + WorkspaceUIContext, +) + +from .eval_utils import ( + EvalChecklist, + count_tool_errors, + create_eval_assistant, + format_message_history, + print_message_history, +) + +# --------------------------------------------------------------------------- +# Eval prompts — one per test, easy to scan for coverage +# --------------------------------------------------------------------------- + +PROMPT_CREATE_PROJECTS_APP = ( + "Create an app showing projects in a list with cards showing " + "project name and status." +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _build_builder_ui_context(user, workspace, builder=None) -> str: + ctx = UIContext( + workspace=WorkspaceUIContext(id=workspace.id, name=workspace.name), + application=ApplicationUIContext(id=str(builder.id), name=builder.name) + if builder + else None, + user=UserUIContext(id=user.id, name=user.first_name, email=user.email), + ) + return ctx.format() + + +def _run_agent( + agent, deps, tracker, model, usage_limits, toolset, question, ui_context +): + deps.tool_helpers.request_context["ui_context"] = ui_context + + ctx = UIContext.model_validate_json(ui_context) + if ctx.application or ctx.page: + deps.mode = AgentMode.APPLICATION + elif ctx.automation or ctx.workflow: + deps.mode = AgentMode.AUTOMATION + else: + deps.mode = AgentMode.DATABASE + + return agent.run_sync( + user_prompt=question, + deps=deps, + model=model, + usage_limits=usage_limits, + toolsets=[toolset], + ) + + +def _get_tool_calls(result, tool_name): + history = format_message_history(result) + return [ + e + for e in history + if e["role"] == "assistant" and e.get("tool_name") == tool_name and "args" in e + ] + + +# --------------------------------------------------------------------------- +# Evals +# --------------------------------------------------------------------------- + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_asks_when_implied_table_missing(data_fixture, eval_model): + """ + Agent should NOT create a table when the user's request implies one exists. + + Scenario: workspace has an "Invoices" table but no "Projects" table. + Prompt: "create an app showing projects in a list". + Expected: agent calls list_tables, finds no match, and asks the user. + Not expected: agent calls create_tables to make a "Projects" table. + """ + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + # Create an unrelated table so list_tables returns something meaningful + database = data_fixture.create_database_application( + user=user, workspace=workspace, name="Finance" + ) + table = data_fixture.create_database_table( + user=user, database=database, name="Invoices" + ) + data_fixture.create_text_field(table=table, name="Invoice Number", primary=True) + + # Provide a builder context so the agent starts in APPLICATION mode + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="My App" + ) + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=15, model=eval_model + ) + ui_context = _build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_CREATE_PROJECTS_APP, + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + history = format_message_history(result) + create_table_calls = _get_tool_calls(result, "create_tables") + list_table_calls = _get_tool_calls(result, "list_tables") + create_page_calls = _get_tool_calls(result, "create_pages") + setup_page_calls = _get_tool_calls(result, "setup_page") + + last_assistant_entries = [e for e in history if e["role"] == "assistant"] + last_assistant = last_assistant_entries[-1] if last_assistant_entries else {} + final_text = last_assistant.get("content", "") + + with EvalChecklist("asks when implied table missing") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "called list_tables to search for 'projects'", + len(list_table_calls) >= 1, + hint=f"tools called: {[e.get('tool_name') for e in history if e.get('tool_name')]}", + ) + checks.check( + "did NOT call create_tables", + len(create_table_calls) == 0, + hint=f"create_tables args: {[c.get('args') for c in create_table_calls]}", + ) + checks.check( + "did NOT create app pages (no matching table found)", + len(create_page_calls) + len(setup_page_calls) == 0, + hint=f"create_pages/setup_page args: {[c.get('args') for c in create_page_calls + setup_page_calls]}", + ) + checks.check( + "agent ended with a text response (asked the user)", + last_assistant.get("type") == "TextPart", + hint=f"last assistant entry type: {last_assistant.get('type')}", + ) + checks.check( + "response asks about projects or requests clarification", + any( + kw in final_text.lower() + for kw in ( + "project", + "which table", + "clarif", + "don't see", + "no table", + "exist", + "could you", + "please", + ) + ), + hint=f"response: {final_text[:300]}", + ) + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_agent_creates_app_when_table_exists(data_fixture, eval_model): + """ + When a matching 'Projects' table exists the agent should build the app + without asking for clarification. + + Expected: + - does NOT call create_tables (reuses existing) + - creates a page + - creates a data source pointing to the Projects table + - creates at least one collection or display element + """ + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + database = data_fixture.create_database_application( + user=user, workspace=workspace, name="Work" + ) + projects_table = data_fixture.create_database_table( + user=user, database=database, name="Projects" + ) + data_fixture.create_text_field(table=projects_table, name="Name", primary=True) + data_fixture.create_text_field(table=projects_table, name="Status") + + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="Project Tracker" + ) + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=25, model=eval_model + ) + ui_context = _build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_CREATE_PROJECTS_APP, + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + history = format_message_history(result) + create_table_calls = _get_tool_calls(result, "create_tables") + create_page_calls = _get_tool_calls(result, "create_pages") + setup_page_calls = _get_tool_calls(result, "setup_page") + ds_calls = _get_tool_calls(result, "create_data_sources") + + pages = Page.objects.filter(builder=builder, shared=False) + + # Collect data source table_ids from args + ds_table_ids = [] + for call in ds_calls: + for ds in call.get("args", {}).get("data_sources", []): + if ds.get("table_id"): + ds_table_ids.append(ds["table_id"]) + + # Collect all element types created + _ELEMENT_TOOLS = { + "create_display_elements", + "create_collection_elements", + "create_layout_elements", + "create_form_elements", + } + el_calls = [ + e + for e in history + if e["role"] == "assistant" + and e.get("tool_name") in _ELEMENT_TOOLS + and "args" in e + ] + all_element_types = [] + for call in el_calls: + all_element_types.extend( + e.get("type") for e in call.get("args", {}).get("elements", []) + ) + + with EvalChecklist("creates app when projects table exists") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "did NOT call create_tables (used existing Projects table)", + len(create_table_calls) == 0, + hint=f"create_tables args: {[c.get('args') for c in create_table_calls]}", + ) + checks.check( + "created at least one page", + len(create_page_calls) + len(setup_page_calls) >= 1, + hint=f"tools called: {[e.get('tool_name') for e in history if e.get('tool_name')]}", + ) + checks.check( + "page exists in DB", + pages.exists(), + hint=f"pages: {list(pages.values_list('name', flat=True))}", + ) + checks.check( + "data source targets Projects table", + projects_table.id in ds_table_ids, + hint=f"data source table_ids: {ds_table_ids}, expected: {projects_table.id}", + ) + checks.check( + "at least one element created", + len(all_element_types) >= 1, + hint=f"element tools called: {[c.get('tool_name') for c in el_calls]}", + ) diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder_user_source.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder_user_source.py new file mode 100644 index 0000000000..7547307e41 --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_builder_user_source.py @@ -0,0 +1,211 @@ +import pytest + +from baserow.core.user_sources.handler import UserSourceHandler +from baserow_enterprise.assistant.types import ( + ApplicationUIContext, + UIContext, + UserUIContext, + WorkspaceUIContext, +) + +from .eval_utils import ( + EvalChecklist, + count_tool_errors, + create_eval_assistant, + format_message_history, + print_message_history, +) + +# --------------------------------------------------------------------------- +# UI context helper +# --------------------------------------------------------------------------- + + +def build_builder_ui_context(user, workspace, builder) -> str: + ctx = UIContext( + workspace=WorkspaceUIContext(id=workspace.id, name=workspace.name), + application=ApplicationUIContext(id=str(builder.id), name=builder.name), + user=UserUIContext(id=user.id, name=user.first_name, email=user.email), + ) + return ctx.format() + + +# --------------------------------------------------------------------------- +# Prompts +# --------------------------------------------------------------------------- + +PROMPT_NEW_TABLE = ( + "In builder '{builder_name}', set up a user source called 'App Users' " + "so users can log in with roles: Admin and Viewer." +) + +PROMPT_EXISTING_TABLE = ( + "In builder '{builder_name}', set up a user source called 'Members' " + "using the existing table '{table_name}'." +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_agent( + agent, deps, tracker, model, usage_limits, toolset, question, ui_context +): + deps.tool_helpers.request_context["ui_context"] = ui_context + + from baserow_enterprise.assistant.deps import AgentMode + + deps.mode = AgentMode.APPLICATION + + return agent.run_sync( + user_prompt=question, + deps=deps, + model=model, + usage_limits=usage_limits, + toolsets=[toolset], + ) + + +def _filter_tool_calls(result, tool_names): + history = format_message_history(result) + calls = [e for e in history if e["role"] == "assistant" and "args" in e] + if isinstance(tool_names, str): + tool_names = {tool_names} + else: + tool_names = set(tool_names) + return [e for e in calls if e.get("tool_name") in tool_names] + + +# --------------------------------------------------------------------------- +# Evals +# --------------------------------------------------------------------------- + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_eval_setup_user_source_new_table(data_fixture, eval_model): + """Agent creates a user source with a brand-new users table.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="My App" + ) + database = data_fixture.create_database_application( + user=user, workspace=workspace, name="My DB" + ) + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=15, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_NEW_TABLE.format( + builder_name=builder.name, database_name=database.name + ), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + setup_calls = _filter_tool_calls(result, "setup_user_source") + user_sources = UserSourceHandler().get_user_sources(builder) + + with EvalChecklist("user source new table") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "called setup_user_source", + len(setup_calls) >= 1, + hint=f"calls: {[e.get('tool_name') for e in format_message_history(result) if e.get('tool_name')]}", + ) + checks.check( + "user source created", + len(user_sources) >= 1, + hint=f"found {len(user_sources)} user sources", + ) + if user_sources: + us = user_sources[0] + roles = us.get_type().get_roles(us) + checks.check( + "has Admin role", + "Admin" in roles, + hint=f"roles: {roles}", + ) + + +@pytest.mark.eval +@pytest.mark.django_db(transaction=True) +def test_eval_setup_user_source_existing_table(data_fixture, eval_model): + """Agent creates a user source using an existing table.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + user=user, workspace=workspace, name="My App" + ) + database = data_fixture.create_database_application( + user=user, workspace=workspace, name="My DB" + ) + table = data_fixture.create_database_table( + database=database, name="Members", user=user + ) + data_fixture.create_text_field(table=table, name="Name", primary=True) + data_fixture.create_email_field(table=table, name="Email") + data_fixture.create_password_field(table=table, name="Password") + data_fixture.create_single_select_field(table=table, name="Role") + + agent, deps, tracker, model, usage_limits, toolset = create_eval_assistant( + user, workspace, max_iters=15, model=eval_model + ) + ui_context = build_builder_ui_context(user, workspace, builder) + + result = _run_agent( + agent, + deps, + tracker, + model, + usage_limits, + toolset, + question=PROMPT_EXISTING_TABLE.format( + builder_name=builder.name, + table_name=table.name, + table_id=table.id, + database_name=database.name, + ), + ui_context=ui_context, + ) + + print_message_history(result) + err_count, err_hint = count_tool_errors(result) + + setup_calls = _filter_tool_calls(result, "setup_user_source") + user_sources = UserSourceHandler().get_user_sources(builder) + + with EvalChecklist("user source existing table") as checks: + checks.check("no tool errors", err_count == 0, hint=err_hint) + checks.check( + "called setup_user_source", + len(setup_calls) >= 1, + hint=f"calls: {[e.get('tool_name') for e in format_message_history(result) if e.get('tool_name')]}", + ) + checks.check( + "user source created", + len(user_sources) >= 1, + hint=f"found {len(user_sources)} user sources", + ) + if user_sources: + us = user_sources[0] + checks.check( + "uses correct table", + us.specific.table_id == table.id, + hint=f"expected table {table.id}, got {us.specific.table_id}", + ) diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_builder_element_move_tools.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_builder_element_move_tools.py new file mode 100644 index 0000000000..75f3be3bd9 --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_builder_element_move_tools.py @@ -0,0 +1,221 @@ +""" +Unit tests for the builder assistant move_elements tool. +""" + +import pytest + +from baserow.contrib.builder.elements.handler import ElementHandler +from baserow_enterprise.assistant.tools.builder.tools import ( + create_display_elements, + create_layout_elements, + move_elements, +) +from baserow_enterprise.assistant.tools.builder.types import ( + DisplayElementCreate, + ElementMove, + LayoutElementCreate, +) + +from .utils import create_fake_tool_helpers, make_test_ctx + + +@pytest.fixture(autouse=True) +def mock_formula_generators(monkeypatch): + """Mock all formula generation to avoid LLM requirement in tests.""" + + def noop(*args, **kwargs): + return [] + + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_element_formulas", + noop, + ) + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_data_source_formulas", + noop, + ) + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_workflow_action_formulas", + noop, + ) + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_single_element_formulas", + noop, + ) + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_single_data_source_formulas", + noop, + ) + + +def _create_two_headings(data_fixture): + """Helper: create a page with two heading elements, return (ctx, page, id1, id2).""" + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="First", level=1), + DisplayElementCreate(ref="h2", type="heading", value="Second", level=2), + ], + thought="test", + ) + + id1 = result["ref_to_id_map"]["h1"] + id2 = result["ref_to_id_map"]["h2"] + return ctx, page, id1, id2 + + +@pytest.mark.django_db(transaction=True) +def test_move_element_before_another(data_fixture): + ctx, page, id1, id2 = _create_two_headings(data_fixture) + + # Move h2 before h1 + result = move_elements( + ctx, + page_id=page.id, + moves=[ElementMove(element_id=id2, before_id=id1)], + thought="reorder", + ) + + assert len(result["moved_elements"]) == 1 + assert result["moved_elements"][0]["element_id"] == id2 + assert "errors" not in result + + # Verify order: h2 should now come before h1 + elements = list(ElementHandler().get_elements(page)) + ids_in_order = [e.id for e in elements] + assert ids_in_order.index(id2) < ids_in_order.index(id1) + + +@pytest.mark.django_db(transaction=True) +def test_move_element_to_end(data_fixture): + ctx, page, id1, id2 = _create_two_headings(data_fixture) + + # Move h1 to end (before_id=None) + result = move_elements( + ctx, + page_id=page.id, + moves=[ElementMove(element_id=id1, before_id=None)], + thought="move to end", + ) + + assert len(result["moved_elements"]) == 1 + assert result["moved_elements"][0]["element_id"] == id1 + + # h1 should now be after h2 + elements = list(ElementHandler().get_elements(page)) + ids_in_order = [e.id for e in elements] + assert ids_in_order.index(id1) > ids_in_order.index(id2) + + +@pytest.mark.django_db(transaction=True) +def test_move_element_into_container(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create a column container + layout_result = create_layout_elements( + ctx, + page_id=page.id, + elements=[LayoutElementCreate(ref="cols", type="column", column_amount=2)], + thought="test", + ) + col_id = layout_result["ref_to_id_map"]["cols"] + + # Create a heading at root level + display_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Hello", level=1), + ], + thought="test", + ) + h1_id = display_result["ref_to_id_map"]["h1"] + + # Move heading into column container, slot "1" + result = move_elements( + ctx, + page_id=page.id, + moves=[ + ElementMove( + element_id=h1_id, + parent_element_id=col_id, + place_in_container="1", + ) + ], + thought="move into container", + ) + + assert len(result["moved_elements"]) == 1 + moved = result["moved_elements"][0] + assert moved["element_id"] == h1_id + assert moved["parent_element_id"] == col_id + assert moved["place_in_container"] == "1" + + +@pytest.mark.django_db(transaction=True) +def test_move_element_to_root(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create column + child heading inside it + layout_result = create_layout_elements( + ctx, + page_id=page.id, + elements=[LayoutElementCreate(ref="cols", type="column", column_amount=2)], + thought="test", + ) + col_id = layout_result["ref_to_id_map"]["cols"] + + display_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate( + ref="h1", + type="heading", + value="Inside", + level=1, + parent_element="cols", + place_in_container="0", + ), + ], + thought="test", + ) + h1_id = display_result["ref_to_id_map"]["h1"] + + # Verify it's inside the container + el = ElementHandler().get_element(h1_id) + assert el.parent_element_id == col_id + + # Move it to root (parent_element_id=None) + result = move_elements( + ctx, + page_id=page.id, + moves=[ElementMove(element_id=h1_id, parent_element_id=None)], + thought="move to root", + ) + + assert len(result["moved_elements"]) == 1 + moved = result["moved_elements"][0] + assert moved["element_id"] == h1_id + assert moved["parent_element_id"] is None diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_builder_tools.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_builder_tools.py new file mode 100644 index 0000000000..0e227cf7e8 --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_builder_tools.py @@ -0,0 +1,2463 @@ +""" +Unit tests for the builder assistant tools. + +Tests cover pages, data sources, elements, and workflow actions using +the RunContext + FunctionToolset pattern. +""" + +import pytest + +from baserow_enterprise.assistant.tools.builder.tools import ( + create_actions, + create_collection_elements, + create_data_sources, + create_display_elements, + create_form_elements, + create_layout_elements, + create_pages, + list_actions, + list_data_sources, + list_elements, + list_pages, + update_data_source, + update_element, + update_element_style, + update_page, +) +from baserow_enterprise.assistant.tools.builder.types import ( + ActionCreate, + ButtonStyleOverride, + CollectionElementCreate, + DataSourceCreate, + DataSourceSort, + DataSourceUpdate, + DisplayElementCreate, + ElementStyleUpdate, + ElementUpdate, + FormElementCreate, + InputStyleOverride, + LayoutElementCreate, + LinkStyleOverride, + MenuItemCreate, + PageCreate, + PagePathParam, + PageUpdate, + TableFieldConfig, + TypographyStyleOverride, +) +from baserow_enterprise.assistant.tools.shared.formula_utils import ( + formula_desc, + literal_or_placeholder, + needs_formula, +) + +from .utils import create_fake_tool_helpers, make_test_ctx + + +@pytest.fixture(autouse=True) +def mock_formula_generators(monkeypatch): + """Mock all formula generation to avoid LLM requirement in tests.""" + + def noop(*args, **kwargs): + return [] + + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_element_formulas", + noop, + ) + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_data_source_formulas", + noop, + ) + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_workflow_action_formulas", + noop, + ) + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_single_element_formulas", + noop, + ) + monkeypatch.setattr( + "baserow_enterprise.assistant.tools.builder.agents.update_single_data_source_formulas", + noop, + ) + + +# =========================================================================== +# Formula utils tests +# =========================================================================== + + +class TestFormulaUtils: + def test_needs_formula_with_prefix(self): + assert needs_formula("$formula: the product name") + assert needs_formula(" $formula: upper case test ") + + def test_needs_formula_with_raw_get(self): + assert needs_formula("get('page_parameter.id')") + assert needs_formula("concat('hello', ' ', get('user.name'))") + + def test_needs_formula_with_raw_expressions(self): + assert needs_formula("if(get('user.is_authenticated'), 'yes', 'no')") + assert needs_formula("today()") + assert needs_formula("now()") + + def test_needs_formula_with_literal(self): + assert not needs_formula("Submit") + assert not needs_formula("'Hello world'") + assert not needs_formula(None) + assert not needs_formula("") + + def test_formula_desc_strips_prefix(self): + assert formula_desc("$formula: the product name") == "the product name" + assert formula_desc(" $formula: spaced ") == "spaced" + + def test_formula_desc_passes_raw(self): + assert formula_desc("get('page_parameter.id')") == "get('page_parameter.id')" + + def test_literal_or_placeholder_formula(self): + assert literal_or_placeholder("$formula: something") == "''" + assert literal_or_placeholder("get('field')") == "''" + + def test_literal_or_placeholder_literal(self): + assert literal_or_placeholder("Submit") == "'Submit'" + assert literal_or_placeholder(None) == "''" + + +# =========================================================================== +# Page tools tests +# =========================================================================== + + +@pytest.mark.django_db +def test_list_pages(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = list_pages(ctx, application_id=builder.id, thought="test") + + assert len(result["pages"]) == 1 + assert result["pages"][0]["name"] == "Home" + assert result["pages"][0]["id"] == page.id + + +@pytest.mark.django_db(transaction=True) +def test_create_pages(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + + ctx = make_test_ctx(user, workspace) + result = create_pages( + ctx, + application_id=builder.id, + pages=[ + PageCreate(name="Home", path="/"), + PageCreate( + name="Product Detail", + path="/products/:id", + path_params=[PagePathParam(name="id", type="numeric")], + ), + ], + thought="test", + ) + + assert len(result["created_pages"]) == 2 + assert result["created_pages"][0]["name"] == "Home" + assert result["created_pages"][1]["name"] == "Product Detail" + assert result["created_pages"][1]["path"] == "/products/:id" + + +@pytest.mark.django_db(transaction=True) +def test_create_pages_skips_duplicates(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = create_pages( + ctx, + application_id=builder.id, + pages=[ + PageCreate(name="Home", path="/"), + PageCreate(name="About", path="/about"), + ], + thought="test", + ) + + assert len(result["created_pages"]) == 1 + assert result["created_pages"][0]["name"] == "About" + assert len(result["existing_pages"]) == 1 + + +# =========================================================================== +# Data source tools tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_create_list_rows_data_source(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + field = data_fixture.create_text_field(table=table, name="Name") + + ctx = make_test_ctx(user, workspace) + result = create_data_sources( + ctx, + page_id=page.id, + data_sources=[ + DataSourceCreate( + ref="products_ds", + name="Products", + type="list_rows", + table_id=table.id, + sortings=[DataSourceSort(field_id=field.id)], + ), + ], + thought="test", + ) + + assert len(result["created_data_sources"]) == 1 + assert result["created_data_sources"][0]["name"] == "Products" + assert result["created_data_sources"][0]["type"] == "list_rows" + assert "products_ds" in result["ref_to_id_map"] + + +@pytest.mark.django_db(transaction=True) +def test_create_get_row_data_source(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page( + builder=builder, name="Detail", path="/detail/:id" + ) + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + + ctx = make_test_ctx(user, workspace) + result = create_data_sources( + ctx, + page_id=page.id, + data_sources=[ + DataSourceCreate( + ref="product_ds", + name="Product", + type="get_row", + table_id=table.id, + row_id="1", + ), + ], + thought="test", + ) + + assert len(result["created_data_sources"]) == 1 + assert result["created_data_sources"][0]["type"] == "get_row" + + +@pytest.mark.django_db +def test_list_data_sources(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = list_data_sources(ctx, page_id=page.id, thought="test") + + assert result["data_sources"] == [] + + +def test_data_source_validation_errors(): + """get_row type requires row_id.""" + with pytest.raises(Exception): + DataSourceCreate( + ref="ds", + name="Test", + type="get_row", + table_id=1, + # Missing row_id + ) + + +# =========================================================================== +# Element tools tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_create_heading_element(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Welcome", level=1), + ], + thought="test", + ) + + assert len(result["created_elements"]) == 1 + assert result["created_elements"][0]["type"] == "heading" + assert result["created_elements"][0]["ref"] == "h1" + + +@pytest.mark.django_db(transaction=True) +def test_create_column_with_children(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + # Create column layout first + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + layout_result = create_layout_elements( + ctx, + page_id=page.id, + elements=[ + LayoutElementCreate(ref="cols", type="column", column_amount=2), + ], + thought="test", + ) + + assert len(layout_result["created_elements"]) == 1 + assert layout_result["created_elements"][0]["type"] == "column" + + # Then add children using display elements + result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate( + ref="left_heading", + type="heading", + value="Left", + parent_element="cols", + place_in_container="0", + ), + DisplayElementCreate( + ref="right_heading", + type="heading", + value="Right", + parent_element="cols", + place_in_container="1", + ), + ], + thought="test", + ) + + assert len(result["created_elements"]) == 2 + assert result["created_elements"][0]["type"] == "heading" + assert result["created_elements"][1]["type"] == "heading" + + +@pytest.mark.django_db(transaction=True) +def test_create_form_container_with_inputs(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Form", path="/form") + + ctx = make_test_ctx(user, workspace) + result = create_form_elements( + ctx, + page_id=page.id, + elements=[ + FormElementCreate( + ref="form", + type="form_container", + submit_button_label="Submit", + ), + FormElementCreate( + ref="name_input", + type="input_text", + label="Name", + placeholder="Enter your name", + required=True, + parent_element="form", + ), + FormElementCreate( + ref="email_input", + type="input_text", + label="Email", + validation_type="email", + required=True, + parent_element="form", + ), + ], + thought="test", + ) + + assert len(result["created_elements"]) == 3 + assert result["created_elements"][0]["type"] == "form_container" + assert result["created_elements"][1]["type"] == "input_text" + assert result["created_elements"][2]["type"] == "input_text" + + +@pytest.mark.django_db +def test_list_elements(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = list_elements(ctx, page_id=page.id, thought="test") + + assert result["elements"] == [] + + +@pytest.mark.django_db(transaction=True) +def test_create_text_and_button(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="txt", type="text", value="Hello world"), + DisplayElementCreate(ref="btn", type="button", value="Click me"), + ], + thought="test", + ) + + assert len(result["created_elements"]) == 2 + assert result["created_elements"][0]["type"] == "text" + assert result["created_elements"][1]["type"] == "button" + + +@pytest.mark.django_db(transaction=True) +def test_create_image_element(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate( + ref="img", + type="image", + image_url="https://example.com/img.png", + alt_text="Example", + ), + ], + thought="test", + ) + + assert len(result["created_elements"]) == 1 + assert result["created_elements"][0]["type"] == "image" + + +# =========================================================================== +# Workflow action tools tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_create_notification_action(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + # Create a button to attach the action to + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="btn", type="button", value="Notify"), + ], + thought="test", + ) + assert len(el_result["created_elements"]) == 1 + + result = create_actions( + ctx, + page_id=page.id, + actions=[ + ActionCreate( + type="notification", + element="btn", + title="'Success!'", + description="'Item was created.'", + ), + ], + thought="test", + ) + + assert len(result["created_actions"]) == 1 + assert result["created_actions"][0]["type"] == "notification" + + +@pytest.mark.django_db(transaction=True) +def test_create_open_page_action(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + target_page = data_fixture.create_builder_page( + builder=builder, name="Detail", path="/detail" + ) + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="link", type="button", value="Go"), + ], + thought="test", + ) + + result = create_actions( + ctx, + page_id=page.id, + actions=[ + ActionCreate( + type="open_page", + element="link", + navigate_to_page_id=target_page.id, + ), + ], + thought="test", + ) + + assert len(result["created_actions"]) == 1 + assert result["created_actions"][0]["type"] == "open_page" + + +def test_open_page_action_extracts_page_param_formulas(): + """open_page actions with $formula: page parameters should produce formulas.""" + + from baserow_enterprise.assistant.tools.builder.types.workflow_action import ( + ParameterMapping, + ) + + action = ActionCreate( + type="open_page", + element="btn", + navigate_to_page_id=99, + page_parameters=[ + ParameterMapping(name="id", value="$formula: the row id"), + ], + ) + formulas = action.get_formulas_to_create(None, None) + assert "page_param_0" in formulas + assert "row id" in formulas["page_param_0"] + + +def test_open_page_action_no_formulas_for_static(): + """open_page actions without $formula: should produce no formulas.""" + + from baserow_enterprise.assistant.tools.builder.types.workflow_action import ( + ParameterMapping, + ) + + action = ActionCreate( + type="open_page", + element="btn", + navigate_to_page_id=99, + page_parameters=[ + ParameterMapping(name="id", value="42"), + ], + ) + formulas = action.get_formulas_to_create(None, None) + assert formulas == {} + + +@pytest.mark.django_db(transaction=True) +def test_create_row_action(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Form", path="/form") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + field = data_fixture.create_text_field(table=table, name="Name") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create form with submit button + el_result = create_form_elements( + ctx, + page_id=page.id, + elements=[ + FormElementCreate(ref="form", type="form_container"), + ], + thought="test", + ) + + from baserow_enterprise.assistant.tools.builder.types import FieldValueMapping + + result = create_actions( + ctx, + page_id=page.id, + actions=[ + ActionCreate( + type="create_row", + element="form", + event="submit", + table_id=table.id, + field_values=[ + FieldValueMapping(field_id=str(field.id), value="'test value'"), + ], + ), + ], + thought="test", + ) + + assert len(result["created_actions"]) == 1 + assert result["created_actions"][0]["type"] == "create_row" + assert result["created_actions"][0]["event"] == "submit" + + +@pytest.mark.django_db +def test_list_actions(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = list_actions(ctx, page_id=page.id, thought="test") + + assert result["workflow_actions"] == [] + + +# =========================================================================== +# add_action_field_mapping tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_add_action_field_mapping_creates_mapping(data_fixture): + """add_action_field_mapping should create a field mapping on an existing action.""" + + from baserow_enterprise.assistant.tools.builder.tools import ( + add_action_field_mapping, + ) + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Form", path="/form") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + name_field = data_fixture.create_text_field(table=table, name="Name") + email_field = data_fixture.create_text_field(table=table, name="Email") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create a form and create_row action with one field mapping + from baserow_enterprise.assistant.tools.builder.types import FieldValueMapping + + create_form_elements( + ctx, + page_id=page.id, + elements=[FormElementCreate(ref="form", type="form_container")], + thought="test", + ) + action_result = create_actions( + ctx, + page_id=page.id, + actions=[ + ActionCreate( + type="create_row", + element="form", + event="submit", + table_id=table.id, + field_values=[ + FieldValueMapping(field_id=str(name_field.id), value="'Alice'"), + ], + ), + ], + thought="test", + ) + action_id = action_result["created_actions"][0]["id"] + + # Now add a second field mapping via add_action_field_mapping + result = add_action_field_mapping( + ctx, + action_id=action_id, + field_id=email_field.id, + value_formula="get('form_data.123')", + thought="test", + ) + + assert result["status"] == "created" + assert len(result["field_mappings"]) == 2 + mapped_field_ids = {m["field_id"] for m in result["field_mappings"]} + assert name_field.id in mapped_field_ids + assert email_field.id in mapped_field_ids + + +@pytest.mark.django_db(transaction=True) +def test_add_action_field_mapping_updates_existing(data_fixture): + """add_action_field_mapping should update an existing field mapping.""" + + from baserow_enterprise.assistant.tools.builder.tools import ( + add_action_field_mapping, + ) + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Form", path="/form") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + name_field = data_fixture.create_text_field(table=table, name="Name") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + from baserow_enterprise.assistant.tools.builder.types import FieldValueMapping + + create_form_elements( + ctx, + page_id=page.id, + elements=[FormElementCreate(ref="form", type="form_container")], + thought="test", + ) + action_result = create_actions( + ctx, + page_id=page.id, + actions=[ + ActionCreate( + type="create_row", + element="form", + event="submit", + table_id=table.id, + field_values=[ + FieldValueMapping(field_id=str(name_field.id), value="'Alice'"), + ], + ), + ], + thought="test", + ) + action_id = action_result["created_actions"][0]["id"] + + # Update the existing mapping with a new formula + result = add_action_field_mapping( + ctx, + action_id=action_id, + field_id=name_field.id, + value_formula="get('form_data.456')", + thought="test", + ) + + assert result["status"] == "updated" + assert len(result["field_mappings"]) == 1 + assert result["field_mappings"][0]["field_id"] == name_field.id + + +# =========================================================================== +# Element ref tracking tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_element_ref_tracking_across_calls(data_fixture): + """Verify that element refs created in one call are available in the next.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # First call: create a button + create_display_elements( + ctx, + page_id=page.id, + elements=[DisplayElementCreate(ref="btn", type="button", value="Click")], + thought="test", + ) + + # Second call: create an action referencing the button from the first call + result = create_actions( + ctx, + page_id=page.id, + actions=[ + ActionCreate(type="notification", element="btn", title="'Hello'"), + ], + thought="test", + ) + + assert len(result["created_actions"]) == 1 + + +# =========================================================================== +# Element update tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_update_heading_value(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create a heading first + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Old Title", level=1), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + # Update the heading value + result = update_element( + ctx, + page_id=page.id, + element=ElementUpdate(element_id=element_id, value="New Title"), + thought="test", + ) + + assert result["status"] == "ok" + assert result["element_id"] == element_id + assert result["element_type"] == "heading" + assert "value" in result["updated_fields"] + + # Verify the update persisted + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + assert el.specific.value["formula"] == "'New Title'" + + +@pytest.mark.django_db(transaction=True) +def test_update_input_text_label(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Form", path="/form") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_form_elements( + ctx, + page_id=page.id, + elements=[ + FormElementCreate( + ref="name_input", + type="input_text", + label="Old Label", + required=False, + ), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + result = update_element( + ctx, + page_id=page.id, + element=ElementUpdate(element_id=element_id, label="New Label", required=True), + thought="test", + ) + + assert result["status"] == "ok" + assert result["element_type"] == "input_text" + assert "label" in result["updated_fields"] + assert "required" in result["updated_fields"] + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id).specific + assert el.label["formula"] == "'New Label'" + assert el.required is True + + +@pytest.mark.django_db(transaction=True) +def test_update_column_amount(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_layout_elements( + ctx, + page_id=page.id, + elements=[ + LayoutElementCreate(ref="cols", type="column", column_amount=2), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + result = update_element( + ctx, + page_id=page.id, + element=ElementUpdate(element_id=element_id, column_amount=3), + thought="test", + ) + + assert result["status"] == "ok" + assert result["element_type"] == "column" + assert "column_amount" in result["updated_fields"] + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id).specific + assert el.column_amount == 3 + + +@pytest.mark.django_db(transaction=True) +def test_update_ignores_irrelevant_fields(data_fixture): + """Update a heading with column_amount — should be dropped by extract_allowed.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Title", level=1), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + # column_amount is irrelevant for heading — should not cause an error + result = update_element( + ctx, + page_id=page.id, + element=ElementUpdate( + element_id=element_id, value="Updated Title", column_amount=3 + ), + thought="test", + ) + + assert result["status"] == "ok" + assert result["element_type"] == "heading" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id).specific + assert el.value["formula"] == "'Updated Title'" + + +@pytest.mark.django_db(transaction=True) +def test_update_with_formula_prefix(data_fixture): + """Verify $formula: triggers placeholder + formula generation.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Static"), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + # The formula prefix should cause a placeholder to be set initially + el_update = ElementUpdate(element_id=element_id, value="$formula: the product name") + + # Check that to_update_kwargs uses placeholder for formula values + kwargs = el_update.to_update_kwargs("heading") + assert kwargs["value"]["formula"] == "''" + + # Check that get_formulas_to_update returns the formula description + formulas = el_update.get_formulas_to_update(None, None, "heading") + assert "value" in formulas + assert "product name" in formulas["value"] + + +def test_update_datetime_picker_formula_detected(): + """datetime_picker with $formula: default_value should trigger formula generation.""" + + el_update = ElementUpdate( + element_id=1, default_value="$formula: get('current_record.field_1439')" + ) + + # to_update_kwargs should set a placeholder for datetime_picker + kwargs = el_update.to_update_kwargs("datetime_picker") + assert "default_value" in kwargs + assert kwargs["default_value"]["formula"] == "''" + + # get_formulas_to_update should detect the formula for datetime_picker + formulas = el_update.get_formulas_to_update(None, None, "datetime_picker") + assert "default_value" in formulas + + +# =========================================================================== +# Page update tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_update_page_name(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = update_page( + ctx, + page=PageUpdate(page_id=page.id, name="Dashboard"), + thought="test", + ) + + assert result["status"] == "ok" + assert result["page"]["name"] == "Dashboard" + assert result["page"]["path"] == "/home" + assert "name" in result["updated_fields"] + assert "path" not in result["updated_fields"] + + +@pytest.mark.django_db(transaction=True) +def test_update_page_path_and_params(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page( + builder=builder, name="Detail", path="/detail" + ) + + ctx = make_test_ctx(user, workspace) + result = update_page( + ctx, + page=PageUpdate( + page_id=page.id, + path="/detail/:id", + path_params=[PagePathParam(name="id", type="numeric")], + ), + thought="test", + ) + + assert result["status"] == "ok" + assert result["page"]["path"] == "/detail/:id" + assert len(result["page"]["path_params"]) == 1 + assert result["page"]["path_params"][0]["name"] == "id" + assert "path" in result["updated_fields"] + assert "path_params" in result["updated_fields"] + + +@pytest.mark.django_db(transaction=True) +def test_update_page_visibility(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + ctx = make_test_ctx(user, workspace) + result = update_page( + ctx, + page=PageUpdate(page_id=page.id, visibility="logged-in"), + thought="test", + ) + + assert result["status"] == "ok" + assert result["page"]["visibility"] == "logged-in" + assert "visibility" in result["updated_fields"] + + +# =========================================================================== +# Data source update tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_update_data_source_name(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create a data source first + ds_result = create_data_sources( + ctx, + page_id=page.id, + data_sources=[ + DataSourceCreate( + ref="ds1", name="Old Name", type="list_rows", table_id=table.id + ), + ], + thought="test", + ) + ds_id = ds_result["created_data_sources"][0]["id"] + + result = update_data_source( + ctx, + page_id=page.id, + data_source=DataSourceUpdate(data_source_id=ds_id, name="New Name"), + thought="test", + ) + + assert result["status"] == "ok" + assert result["data_source_id"] == ds_id + assert "name" in result["updated_fields"] + + from baserow.contrib.builder.data_sources.handler import DataSourceHandler + + ds = DataSourceHandler().get_data_source(ds_id) + assert ds.name == "New Name" + + +@pytest.mark.django_db(transaction=True) +def test_update_data_source_table(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table1 = data_fixture.create_database_table(user=user, database=database) + table2 = data_fixture.create_database_table(user=user, database=database) + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + ds_result = create_data_sources( + ctx, + page_id=page.id, + data_sources=[ + DataSourceCreate( + ref="ds1", name="Products", type="list_rows", table_id=table1.id + ), + ], + thought="test", + ) + ds_id = ds_result["created_data_sources"][0]["id"] + + result = update_data_source( + ctx, + page_id=page.id, + data_source=DataSourceUpdate(data_source_id=ds_id, table_id=table2.id), + thought="test", + ) + + assert result["status"] == "ok" + assert "table_id" in result["updated_fields"] + + from baserow.contrib.builder.data_sources.handler import DataSourceHandler + + ds = DataSourceHandler().get_data_source(ds_id) + assert ds.service.specific.table_id == table2.id + + +def test_update_data_source_formula_detected(): + """$formula: row_id should trigger formula generation.""" + + ds_update = DataSourceUpdate( + data_source_id=1, row_id="$formula: the id from the page parameter" + ) + + formulas = ds_update.get_formulas_to_update(None, None) + assert "row_id" in formulas + assert "page parameter" in formulas["row_id"] + + +def test_update_data_source_search_query_formula(): + """$formula: search_query should trigger formula generation.""" + + ds_update = DataSourceUpdate( + data_source_id=1, search_query="$formula: the search input text" + ) + + formulas = ds_update.get_formulas_to_update(None, None) + assert "search_query" in formulas + + +# --------------------------------------------------------------------------- +# Shared element tests +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db(transaction=True) +def test_create_menu_with_items(data_fixture): + """Creating a menu element with menu_items should produce MenuItemElement rows.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/") + page2 = data_fixture.create_builder_page( + builder=builder, name="About", path="/about" + ) + + ctx = make_test_ctx(user, workspace) + result = create_layout_elements( + ctx, + page_id=page.id, + elements=[ + LayoutElementCreate( + ref="hdr", + type="header", + ), + LayoutElementCreate( + ref="nav", + type="menu", + parent_element="hdr", + menu_items=[ + MenuItemCreate(name="Home", page_id=page.id), + MenuItemCreate(name="About", page_id=page2.id), + ], + ), + ], + thought="test", + ) + + assert len(result["created_elements"]) == 2 + + # Menu should be on the shared page (child of header) + from baserow.contrib.builder.elements.handler import ElementHandler + + menu_id = result["ref_to_id_map"]["nav"] + menu_el = ElementHandler().get_element(menu_id).specific + assert menu_el.page.shared, "Menu should be on the shared page" + + items = list(menu_el.menu_items.all().order_by("menu_item_order")) + assert len(items) == 2 + assert items[0].name == "Home" + assert items[0].navigate_to_page_id == page.id + assert items[1].name == "About" + assert items[1].navigate_to_page_id == page2.id + + +@pytest.mark.django_db(transaction=True) +def test_update_menu_items(data_fixture): + """update_element with menu_items should replace the menu's items.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/") + page2 = data_fixture.create_builder_page( + builder=builder, name="About", path="/about" + ) + page3 = data_fixture.create_builder_page( + builder=builder, name="Contact", path="/contact" + ) + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create header + menu with 1 item + result = create_layout_elements( + ctx, + page_id=page.id, + elements=[ + LayoutElementCreate(ref="hdr", type="header"), + LayoutElementCreate( + ref="nav", + type="menu", + parent_element="hdr", + menu_items=[MenuItemCreate(name="Home", page_id=page.id)], + ), + ], + thought="test", + ) + menu_id = result["ref_to_id_map"]["nav"] + + # Update to 3 items + update_result = update_element( + ctx, + page_id=page.id, + element=ElementUpdate( + element_id=menu_id, + menu_items=[ + MenuItemCreate(name="Home", page_id=page.id), + MenuItemCreate(name="About", page_id=page2.id), + MenuItemCreate(name="Contact", page_id=page3.id), + ], + ), + thought="test", + ) + + assert update_result["status"] == "ok" + assert "menu_items" in update_result["updated_fields"] + + from baserow.contrib.builder.elements.handler import ElementHandler + + menu_el = ElementHandler().get_element(menu_id).specific + items = list(menu_el.menu_items.all().order_by("menu_item_order")) + assert len(items) == 3 + assert items[0].name == "Home" + assert items[1].name == "About" + assert items[1].navigate_to_page_id == page2.id + assert items[2].name == "Contact" + assert items[2].navigate_to_page_id == page3.id + + +# =========================================================================== +# Element style tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_box_model(data_fixture): + """Set border, padding, margin, background, width — all sides uniform.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Title", level=1), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + border_color="#ff0000", + border_size=2, + padding=30, + margin=10, + border_radius=8, + background="color", + background_color="#00ff00", + width="full", + ), + thought="test", + ) + + assert result["status"] == "ok" + assert result["element_type"] == "heading" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + for side in ("top", "bottom", "left", "right"): + assert getattr(el, f"style_border_{side}_color") == "#ff0000" + assert getattr(el, f"style_border_{side}_size") == 2 + assert getattr(el, f"style_padding_{side}") == 30 + assert getattr(el, f"style_margin_{side}") == 10 + assert el.style_border_radius == 8 + assert el.style_background == "color" + assert el.style_background_color == "#00ff00" + assert el.style_width == "full" + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_reset(data_fixture): + """Apply custom styles, then reset to defaults.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Title", level=1), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + # Apply custom styles + update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + padding=50, + border_size=5, + background="color", + background_color="#123456", + ), + thought="test", + ) + + # Reset + result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate(element_id=element_id, reset=True), + thought="test", + ) + + assert result["status"] == "ok" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + assert el.style_padding_top == 10 + assert el.style_padding_left == 20 + assert el.style_border_top_size == 0 + assert el.style_background == "none" + assert el.style_background_color == "#ffffffff" + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_reset_with_overrides(data_fixture): + """reset=True + padding=50 → padding=50 all sides, rest at defaults.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Title", level=1), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate(element_id=element_id, reset=True, padding=50), + thought="test", + ) + + assert result["status"] == "ok" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + for side in ("top", "bottom", "left", "right"): + assert getattr(el, f"style_padding_{side}") == 50 + # Other fields at defaults + assert el.style_border_top_size == 0 + assert el.style_background == "none" + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_partial(data_fixture): + """Only set background_color — other fields untouched.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Title", level=1), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + from baserow.contrib.builder.elements.handler import ElementHandler + + el_before = ElementHandler().get_element(element_id) + padding_before = el_before.style_padding_top + + result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + background_color="#abcdef", + ), + thought="test", + ) + + assert result["status"] == "ok" + assert result["updated_fields"] == ["style_background_color"] + + el = ElementHandler().get_element(element_id) + assert el.style_background_color == "#abcdef" + assert el.style_padding_top == padding_before # unchanged + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_button_overrides(data_fixture): + """Button element with button style overrides → styles JSON.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="btn", type="button", value="Click me"), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + button=ButtonStyleOverride( + background_color="#ff0000", + text_color="#ffffff", + ), + ), + thought="test", + ) + + assert result["status"] == "ok" + assert result["element_type"] == "button" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + styles = el.styles + assert styles["button"]["button_background_color"] == "#ff0000" + assert styles["button"]["button_text_color"] == "#ffffff" + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_typography_overrides(data_fixture): + """Heading element with typography overrides → styles JSON.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Title", level=1), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + typography=TypographyStyleOverride( + heading_1_text_color="#333333", + heading_1_font_size=32, + heading_1_text_alignment="center", + ), + ), + thought="test", + ) + + assert result["status"] == "ok" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + styles = el.styles + assert styles["typography"]["heading_1_text_color"] == "#333333" + assert styles["typography"]["heading_1_font_size"] == 32 + assert styles["typography"]["heading_1_text_alignment"] == "center" + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_input_overrides(data_fixture): + """Input text element with input overrides → styles JSON.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Form", path="/form") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_form_elements( + ctx, + page_id=page.id, + elements=[ + FormElementCreate(ref="inp", type="input_text", label="Name"), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + input=InputStyleOverride( + input_background_color="#f0f0f0", + input_border_color="#cccccc", + label_text_color="#666666", + ), + ), + thought="test", + ) + + assert result["status"] == "ok" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + styles = el.styles + assert styles["input"]["input_background_color"] == "#f0f0f0" + assert styles["input"]["input_border_color"] == "#cccccc" + assert styles["input"]["label_text_color"] == "#666666" + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_ignores_wrong_block(data_fixture): + """On a heading, button overrides should be ignored (wrong type).""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="h1", type="heading", value="Title", level=1), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + button=ButtonStyleOverride(background_color="#ff0000"), + ), + thought="test", + ) + + # Button overrides on a heading should be silently ignored, returning a warning + assert result["status"] == "warning" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + # styles should not contain button block + assert "button" not in (el.styles or {}) + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_combined(data_fixture): + """Box model + theme overrides in one call.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="btn", type="button", value="Go"), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + padding=20, + border_color="#000000", + border_size=1, + button=ButtonStyleOverride( + background_color="#0000ff", + text_color="#ffffff", + ), + ), + thought="test", + ) + + assert result["status"] == "ok" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + # Box model + assert el.style_padding_top == 20 + assert el.style_border_top_color == "#000000" + assert el.style_border_top_size == 1 + # Theme overrides + assert el.styles["button"]["button_background_color"] == "#0000ff" + assert el.styles["button"]["button_text_color"] == "#ffffff" + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_merges_existing_overrides(data_fixture): + """Second style call should merge with existing overrides, not wipe them.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + el_result = create_display_elements( + ctx, + page_id=page.id, + elements=[ + DisplayElementCreate(ref="btn", type="button", value="Go"), + ], + thought="test", + ) + element_id = el_result["created_elements"][0]["id"] + + # First call: set font_size and background_color + update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + button=ButtonStyleOverride( + font_size=18, + background_color="#0000ff", + ), + ), + thought="test", + ) + + # Second call: only change text_color + update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=element_id, + button=ButtonStyleOverride(text_color="#ffffff"), + ), + thought="test", + ) + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(element_id) + btn = el.styles["button"] + # First call's values should still be present + assert btn["button_font_size"] == 18 + assert btn["button_background_color"] == "#0000ff" + # Second call's value should be added + assert btn["button_text_color"] == "#ffffff" + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_menu_link_color(data_fixture): + """Menu element link overrides should store under 'menu' key, not 'link'.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create header + menu + result = create_layout_elements( + ctx, + page_id=page.id, + elements=[ + LayoutElementCreate(ref="hdr", type="header"), + LayoutElementCreate( + ref="nav", + type="menu", + parent_element="hdr", + menu_items=[MenuItemCreate(name="Home", page_id=page.id)], + ), + ], + thought="test", + ) + menu_id = result["ref_to_id_map"]["nav"] + + # Set link color to red on menu + style_result = update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=menu_id, + link=LinkStyleOverride(text_color="#ff0000"), + ), + thought="test", + ) + + assert style_result["status"] == "ok" + assert style_result["element_type"] == "menu" + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(menu_id) + # Link props go under "menu" key for menu elements + assert "menu" in el.styles + assert el.styles["menu"]["link_text_color"] == "#ff0000" + # Should NOT have a separate "link" key + assert "link" not in el.styles + + +@pytest.mark.django_db(transaction=True) +def test_update_element_style_menu_button_and_link(data_fixture): + """Menu element: both button and link overrides merge under 'menu' key.""" + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + result = create_layout_elements( + ctx, + page_id=page.id, + elements=[ + LayoutElementCreate(ref="hdr", type="header"), + LayoutElementCreate( + ref="nav", + type="menu", + parent_element="hdr", + menu_items=[MenuItemCreate(name="Home", page_id=page.id)], + ), + ], + thought="test", + ) + menu_id = result["ref_to_id_map"]["nav"] + + update_element_style( + ctx, + page_id=page.id, + style=ElementStyleUpdate( + element_id=menu_id, + button=ButtonStyleOverride(background_color="#0000ff"), + link=LinkStyleOverride(text_color="#ff0000"), + ), + thought="test", + ) + + from baserow.contrib.builder.elements.handler import ElementHandler + + el = ElementHandler().get_element(menu_id) + menu_styles = el.styles["menu"] + assert menu_styles["button_background_color"] == "#0000ff" + assert menu_styles["link_text_color"] == "#ff0000" + + +# =========================================================================== +# Table element property options tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_table_element_auto_enables_filter_sort_search(data_fixture): + """Table text columns referencing real fields get filter/sort/search enabled.""" + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + name_field = data_fixture.create_text_field(table=table, name="Name") + email_field = data_fixture.create_text_field(table=table, name="Email") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create a data source first + ds_result = create_data_sources( + ctx, + page_id=page.id, + data_sources=[ + DataSourceCreate( + ref="ds1", + name="People", + type="list_rows", + table_id=table.id, + ), + ], + thought="test", + ) + ds_id = ds_result["ref_to_id_map"]["ds1"] + + # Create a table with 2 text columns + 1 button column + result = create_collection_elements( + ctx, + page_id=page.id, + elements=[ + CollectionElementCreate( + ref="tbl", + type="table", + data_source=ds_id, + fields=[ + TableFieldConfig(name="Name", type="text"), + TableFieldConfig(name="Email", type="text"), + TableFieldConfig(name="Actions", type="button", label="Edit"), + ], + ), + ], + thought="test", + ) + + table_element_id = result["ref_to_id_map"]["tbl"] + + from baserow.contrib.builder.elements.models import CollectionElementPropertyOptions + + options = list( + CollectionElementPropertyOptions.objects.filter( + element_id=table_element_id + ).order_by("schema_property") + ) + + # Should have options for both text columns, not for the button column + assert len(options) == 2 + + schema_props = {o.schema_property for o in options} + assert f"field_{name_field.id}" in schema_props + assert f"field_{email_field.id}" in schema_props + + for opt in options: + assert opt.filterable is True + assert opt.sortable is True + assert opt.searchable is True + + +@pytest.mark.django_db(transaction=True) +def test_update_table_element_replace_columns(data_fixture): + """Updating a table element's fields replaces all columns.""" + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + name_field = data_fixture.create_text_field(table=table, name="Name") + email_field = data_fixture.create_text_field(table=table, name="Email") + phone_field = data_fixture.create_text_field(table=table, name="Phone") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + # Create data source + ds_result = create_data_sources( + ctx, + page_id=page.id, + data_sources=[ + DataSourceCreate( + ref="ds1", name="People", type="list_rows", table_id=table.id + ), + ], + thought="test", + ) + ds_id = ds_result["ref_to_id_map"]["ds1"] + + # Create table with 2 columns + el_result = create_collection_elements( + ctx, + page_id=page.id, + elements=[ + CollectionElementCreate( + ref="tbl", + type="table", + data_source=ds_id, + fields=[ + TableFieldConfig(name="Name", type="text"), + TableFieldConfig(name="Email", type="text"), + ], + ), + ], + thought="test", + ) + table_element_id = el_result["ref_to_id_map"]["tbl"] + + from baserow.contrib.builder.elements.handler import ElementHandler + + element = ElementHandler().get_element(table_element_id).specific + + # Verify initial state: 2 columns + fields_before = list(element.fields.order_by("order")) + assert len(fields_before) == 2 + assert fields_before[0].name == "Name" + assert fields_before[1].name == "Email" + + # Update: replace with 3 columns (add Phone, remove Email) + update_element( + ctx, + page_id=page.id, + element=ElementUpdate( + element_id=table_element_id, + fields=[ + TableFieldConfig(name="Name", type="text"), + TableFieldConfig(name="Phone", type="text"), + TableFieldConfig(name="Actions", type="button", label="Edit"), + ], + ), + thought="test", + ) + + element = ElementHandler().get_element(table_element_id).specific + fields_after = list(element.fields.order_by("order")) + assert len(fields_after) == 3 + assert fields_after[0].name == "Name" + assert fields_after[1].name == "Phone" + assert fields_after[2].name == "Actions" + assert fields_after[2].type == "button" + + +@pytest.mark.django_db(transaction=True) +def test_update_table_element_add_fields(data_fixture): + """add_fields appends columns without touching existing ones.""" + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + data_fixture.create_text_field(table=table, name="Name") + data_fixture.create_text_field(table=table, name="Email") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + ds_result = create_data_sources( + ctx, + page_id=page.id, + data_sources=[ + DataSourceCreate( + ref="ds1", name="People", type="list_rows", table_id=table.id + ), + ], + thought="test", + ) + ds_id = ds_result["ref_to_id_map"]["ds1"] + + # Create table with 1 column + el_result = create_collection_elements( + ctx, + page_id=page.id, + elements=[ + CollectionElementCreate( + ref="tbl", + type="table", + data_source=ds_id, + fields=[TableFieldConfig(name="Name", type="text")], + ), + ], + thought="test", + ) + table_element_id = el_result["ref_to_id_map"]["tbl"] + + from baserow.contrib.builder.elements.handler import ElementHandler + + element = ElementHandler().get_element(table_element_id).specific + assert element.fields.count() == 1 + + # Add Email column — Name should be preserved + update_element( + ctx, + page_id=page.id, + element=ElementUpdate( + element_id=table_element_id, + add_fields=[TableFieldConfig(name="Email", type="text")], + ), + thought="test", + ) + + element = ElementHandler().get_element(table_element_id).specific + fields = list(element.fields.order_by("order")) + assert len(fields) == 2 + assert fields[0].name == "Name" + assert fields[1].name == "Email" + + +@pytest.mark.django_db(transaction=True) +def test_update_table_element_remove_fields(data_fixture): + """remove_fields removes columns by name, preserving the rest.""" + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + data_fixture.create_text_field(table=table, name="Name") + data_fixture.create_text_field(table=table, name="Email") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + ds_result = create_data_sources( + ctx, + page_id=page.id, + data_sources=[ + DataSourceCreate( + ref="ds1", name="People", type="list_rows", table_id=table.id + ), + ], + thought="test", + ) + ds_id = ds_result["ref_to_id_map"]["ds1"] + + # Create table with 2 columns + el_result = create_collection_elements( + ctx, + page_id=page.id, + elements=[ + CollectionElementCreate( + ref="tbl", + type="table", + data_source=ds_id, + fields=[ + TableFieldConfig(name="Name", type="text"), + TableFieldConfig(name="Email", type="text"), + ], + ), + ], + thought="test", + ) + table_element_id = el_result["ref_to_id_map"]["tbl"] + + from baserow.contrib.builder.elements.handler import ElementHandler + + element = ElementHandler().get_element(table_element_id).specific + assert element.fields.count() == 2 + + # Remove Email by name — Name should be preserved + update_element( + ctx, + page_id=page.id, + element=ElementUpdate( + element_id=table_element_id, + remove_fields=["Email"], + ), + thought="test", + ) + + element = ElementHandler().get_element(table_element_id).specific + fields = list(element.fields.order_by("order")) + assert len(fields) == 1 + assert fields[0].name == "Name" + + +@pytest.mark.django_db(transaction=True) +def test_update_table_element_add_and_remove_fields(data_fixture): + """add_fields and remove_fields can be combined in a single update.""" + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + page = data_fixture.create_builder_page(builder=builder, name="Home", path="/home") + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(user=user, database=database) + data_fixture.create_text_field(table=table, name="Name") + data_fixture.create_text_field(table=table, name="Email") + data_fixture.create_text_field(table=table, name="Phone") + + tool_helpers = create_fake_tool_helpers() + ctx = make_test_ctx(user, workspace, tool_helpers) + + ds_result = create_data_sources( + ctx, + page_id=page.id, + data_sources=[ + DataSourceCreate( + ref="ds1", name="People", type="list_rows", table_id=table.id + ), + ], + thought="test", + ) + ds_id = ds_result["ref_to_id_map"]["ds1"] + + el_result = create_collection_elements( + ctx, + page_id=page.id, + elements=[ + CollectionElementCreate( + ref="tbl", + type="table", + data_source=ds_id, + fields=[ + TableFieldConfig(name="Name", type="text"), + TableFieldConfig(name="Email", type="text"), + ], + ), + ], + thought="test", + ) + table_element_id = el_result["ref_to_id_map"]["tbl"] + + # Remove Email, add Phone + button — in one call + update_element( + ctx, + page_id=page.id, + element=ElementUpdate( + element_id=table_element_id, + remove_fields=["Email"], + add_fields=[ + TableFieldConfig(name="Phone", type="text"), + TableFieldConfig(name="Actions", type="button", label="Edit"), + ], + ), + thought="test", + ) + + from baserow.contrib.builder.elements.handler import ElementHandler + + element = ElementHandler().get_element(table_element_id).specific + fields = list(element.fields.order_by("order")) + assert len(fields) == 3 + assert fields[0].name == "Name" + assert fields[1].name == "Phone" + assert fields[2].name == "Actions" + assert fields[2].type == "button" + + +# =========================================================================== +# User source tools tests +# =========================================================================== + + +@pytest.mark.django_db(transaction=True) +def test_setup_user_source_new_table(data_fixture): + """Create a user source with a brand-new users table.""" + + from baserow.contrib.database.fields.models import Field + from baserow.core.db import specific_iterator + from baserow_enterprise.assistant.tools.builder.tools import setup_user_source + from baserow_enterprise.assistant.tools.builder.types.user_source import ( + UserSourceSetup, + ) + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + database = data_fixture.create_database_application(user=user, workspace=workspace) + + ctx = make_test_ctx(user, workspace) + result = setup_user_source( + ctx, + application_id=builder.id, + setup=UserSourceSetup( + name="App Users", + database_id=database.id, + roles=["Admin", "Editor"], + ), + thought="test", + ) + + assert "user_source_id" in result + assert "table_id" in result + assert "Admin" in result["roles"] + assert "Editor" in result["roles"] + + # Verify table fields + table_fields = list( + specific_iterator( + Field.objects.filter(table_id=result["table_id"]) + .order_by("order") + .select_related("content_type") + ) + ) + field_names = [f.name for f in table_fields] + assert "Name" in field_names + assert "Email" in field_names + assert "Password" in field_names + assert "Role" in field_names + + # Verify example rows (one per role) + from baserow.contrib.database.table.models import Table + + table = Table.objects.get(id=result["table_id"]) + model = table.get_model() + rows = list(model.objects.all()) + assert len(rows) == 2 # Admin + Editor + + # Verify user source has auth provider + from baserow.core.user_sources.handler import UserSourceHandler + + us = UserSourceHandler().get_user_source(result["user_source_id"]) + assert us.table_id == result["table_id"] + providers = list(us.auth_providers.all()) + assert len(providers) == 1 + + # Verify login page was created with auth_form element + assert "login_page_id" in result + builder.refresh_from_db() + assert builder.login_page_id == result["login_page_id"] + + from baserow.contrib.builder.elements.handler import ElementHandler + from baserow.contrib.builder.pages.handler import PageHandler + + login_page = PageHandler().get_page(result["login_page_id"]) + elements = ElementHandler().get_elements(login_page) + auth_forms = [e for e in elements if e.get_type().type == "auth_form"] + assert len(auth_forms) == 1 + assert auth_forms[0].specific.user_source_id == result["user_source_id"] + + +@pytest.mark.django_db(transaction=True) +def test_setup_user_source_existing_table(data_fixture): + """Use an existing table that has the required fields.""" + + from baserow_enterprise.assistant.tools.builder.tools import setup_user_source + from baserow_enterprise.assistant.tools.builder.types.user_source import ( + UserSourceSetup, + ) + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Members") + data_fixture.create_text_field(table=table, name="Name", primary=True) + data_fixture.create_email_field(table=table, name="Email") + data_fixture.create_password_field(table=table, name="Password") + + ctx = make_test_ctx(user, workspace) + result = setup_user_source( + ctx, + application_id=builder.id, + setup=UserSourceSetup(name="Members Source", table_id=table.id), + thought="test", + ) + + assert result["table_id"] == table.id + assert "user_source_id" in result + + from baserow.core.user_sources.handler import UserSourceHandler + + us = UserSourceHandler().get_user_source(result["user_source_id"]) + assert us.table_id == table.id + + +@pytest.mark.django_db(transaction=True) +def test_setup_user_source_existing_table_creates_password_field(data_fixture): + """If the existing table lacks a password field, one is created.""" + + from baserow.contrib.database.fields.models import Field + from baserow.core.db import specific_iterator + from baserow_enterprise.assistant.tools.builder.tools import setup_user_source + from baserow_enterprise.assistant.tools.builder.types.user_source import ( + UserSourceSetup, + ) + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application(user=user, workspace=workspace) + database = data_fixture.create_database_application(user=user, workspace=workspace) + table = data_fixture.create_database_table(database=database, name="People") + data_fixture.create_text_field(table=table, name="Name", primary=True) + data_fixture.create_email_field(table=table, name="Email") + # No password field + + ctx = make_test_ctx(user, workspace) + result = setup_user_source( + ctx, + application_id=builder.id, + setup=UserSourceSetup(name="People Source", table_id=table.id), + thought="test", + ) + + assert result["table_id"] == table.id + + # Verify password field was created + table_fields = list( + specific_iterator( + Field.objects.filter(table=table) + .order_by("order") + .select_related("content_type") + ) + ) + from baserow.contrib.database.fields.models import PasswordField + + password_fields = [f for f in table_fields if isinstance(f, PasswordField)] + assert len(password_fields) == 1 + assert password_fields[0].name == "Password" diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantPanel.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantPanel.vue index b5bbf2b8f0..a03f93b41f 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantPanel.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantPanel.vue @@ -189,6 +189,24 @@ export default { workspaceId: this.workspace.id, }, }) + } else if (newLocation.type === 'builder-page') { + waitFor(() => { + const builder = store.getters['application/get']( + newLocation.application_id + ) + return ( + builder && + builder.pages.find((page) => page.id === newLocation.page_id) + ) + }).then(() => { + router.push({ + name: 'builder-page', + params: { + builderId: newLocation.application_id, + pageId: newLocation.page_id, + }, + }) + }) } else if (newLocation.type === 'automation-workflow') { waitFor(() => { const automation = store.getters['application/get']( diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantUiContext.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantUiContext.vue index 145c87101c..7a5be79af2 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantUiContext.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantUiContext.vue @@ -30,6 +30,8 @@ export default { return this.uiContext.table.name } else if (this.uiContext.workflow) { return this.uiContext.workflow.name + } else if (this.uiContext.page) { + return this.uiContext.page.name } else if (this.uiContext.application) { return this.uiContext.application.name } else if (this.uiContext.workspace) { diff --git a/enterprise/web-frontend/modules/baserow_enterprise/store/assistant.js b/enterprise/web-frontend/modules/baserow_enterprise/store/assistant.js index c6b148eebe..d8aca9fd1b 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/store/assistant.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/store/assistant.js @@ -454,6 +454,16 @@ export const getters = { uiContext.workflow = { id: workflow.id, name: workflow.name } } } catch {} + + if (application?.type === 'builder') { + if (scope.page && application.pages) { + const page = application.pages.find((p) => p.id === scope.page) + if (page) { + uiContext.page = { id: page.id, name: page.name } + } + } + } + return uiContext }, diff --git a/web-frontend/modules/builder/store/page.js b/web-frontend/modules/builder/store/page.js index 28149d561f..17f4b18fbb 100644 --- a/web-frontend/modules/builder/store/page.js +++ b/web-frontend/modules/builder/store/page.js @@ -4,6 +4,7 @@ import PageService from '@baserow/modules/builder/services/page' import { generateHash } from '@baserow/modules/core/utils/hashing' import { pageFinished } from '@baserow/modules/core/utils/routing' import { nextTick } from '#imports' +import { BUILDER_ACTION_SCOPES } from '@baserow/modules/builder/utils/undoRedoConstants' export function populatePage(page) { return { @@ -86,7 +87,7 @@ const actions = { forceCreate({ commit }, { builder, page }) { commit('ADD_ITEM', { builder, page }) }, - selectById({ commit, getters }, { builder, pageId }) { + selectById({ commit, getters, dispatch }, { builder, pageId }) { const type = BuilderApplicationType.getType() // Check if the just selected application is a builder @@ -99,13 +100,24 @@ const actions = { // Check if the provided page id is found in the just selected builder. const page = getters.getById(builder, pageId) + dispatch( + 'undoRedo/updateCurrentScopeSet', + BUILDER_ACTION_SCOPES.page(page.id), + { root: true } + ) + commit('UNSELECT') commit('SET_SELECTED', { builder, page }) return page }, - unselect({ commit }) { + unselect({ commit, dispatch }) { commit('UNSELECT') + dispatch( + 'undoRedo/updateCurrentScopeSet', + BUILDER_ACTION_SCOPES.page(null), + { root: true } + ) }, async forceDelete({ commit }, { builder, page }) { if (page._.selected) { diff --git a/web-frontend/modules/builder/utils/undoRedoConstants.js b/web-frontend/modules/builder/utils/undoRedoConstants.js new file mode 100644 index 0000000000..477781862b --- /dev/null +++ b/web-frontend/modules/builder/utils/undoRedoConstants.js @@ -0,0 +1,8 @@ +// The different types of undo/redo scopes available for the builder module. +export const BUILDER_ACTION_SCOPES = { + page(pageId) { + return { + page: pageId, + } + }, +} From ad15983376a18d97112f1b61704fcea9edd0478e Mon Sep 17 00:00:00 2001 From: Frederik Duchi <35960131+frederikduchi@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:57:38 +0200 Subject: [PATCH 3/4] addition of templates with automations (#5046) --- backend/templates/ab-testing.json | 15141 ++++++ backend/templates/ab-testing.zip | Bin 0 -> 82154 bytes backend/templates/inspections-compliance.json | 2720 +- backend/templates/inspections-compliance.zip | Bin 66988 -> 66988 bytes backend/templates/intake-qualification.json | 2 +- backend/templates/password-reset.json | 3116 ++ backend/templates/password-reset.zip | Bin 0 -> 576917 bytes backend/templates/program-management-kpi.json | 12080 +++++ backend/templates/program-management-kpi.zip | Bin 0 -> 247298 bytes .../templates/work-management-platform.json | 42611 ++++++++++++++++ .../templates/work-management-platform.zip | Bin 0 -> 969678 bytes 11 files changed, 74309 insertions(+), 1361 deletions(-) create mode 100644 backend/templates/ab-testing.json create mode 100644 backend/templates/ab-testing.zip create mode 100644 backend/templates/password-reset.json create mode 100644 backend/templates/password-reset.zip create mode 100644 backend/templates/program-management-kpi.json create mode 100644 backend/templates/program-management-kpi.zip create mode 100644 backend/templates/work-management-platform.json create mode 100644 backend/templates/work-management-platform.zip diff --git a/backend/templates/ab-testing.json b/backend/templates/ab-testing.json new file mode 100644 index 0000000000..a6acf002ac --- /dev/null +++ b/backend/templates/ab-testing.json @@ -0,0 +1,15141 @@ +{ + "baserow_template_version": 1, + "name": "A/B Testing", + "icon": "iconoir-test-tube", + "keywords": [ + "experiments", + "variants", + "A/B testing", + "campaigns", + "hypothesis", + "priority score", + "confidence score", + "opportunity index" + ], + "categories": [ + "Marketing" + ], + "open_application": 343, + "export": [ + { + "id": 343, + "name": "A/B testing", + "order": 1, + "type": "database", + "tables": [ + { + "id": 963, + "name": "Employees", + "order": 1, + "fields": [ + { + "id": 9030, + "type": "text", + "name": "Name", + "description": null, + "order": 0, + "primary": true, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9031, + "type": "email", + "name": "Email", + "description": null, + "order": 1, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [] + }, + { + "id": 9032, + "type": "phone_number", + "name": "Phone", + "description": null, + "order": 2, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [] + }, + { + "id": 9033, + "type": "link_row", + "name": "Experiments", + "description": null, + "order": 3, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 968, + "link_row_related_field_id": 9059, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": true + } + ], + "views": [ + { + "id": 4411, + "type": "grid", + "name": "All employees", + "order": 1, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36651, + "field_id": 9030, + "width": 200, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36652, + "field_id": 9031, + "width": 233, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36653, + "field_id": 9032, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36654, + "field_id": 9033, + "width": 200, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + } + ], + "rows": [ + { + "id": 1, + "order": "1.00000000000000000000", + "created_on": "2025-11-13T08:51:48.262523+00:00", + "updated_on": "2026-01-12T12:47:41.900689+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9030": "Allie Ecker", + "field_9031": "allie.ecker@example.com", + "field_9032": "(949) 873-7292", + "field_9033": [ + 3 + ] + }, + { + "id": 2, + "order": "2.00000000000000000000", + "created_on": "2025-11-13T08:51:48.262580+00:00", + "updated_on": "2026-01-12T12:47:41.900773+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9030": "Bran Lopez", + "field_9031": "bran.lopez@example.com", + "field_9032": "(650) 869-3623", + "field_9033": [ + 4 + ] + }, + { + "id": 3, + "order": "3.00000000000000000000", + "created_on": "2025-11-13T08:51:48.262615+00:00", + "updated_on": "2026-01-12T12:47:41.900820+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9030": "Cinda Pullen", + "field_9031": "cinda.pullen@example.com", + "field_9032": "(213) 743-1636", + "field_9033": [ + 5 + ] + }, + { + "id": 4, + "order": "4.00000000000000000000", + "created_on": "2025-11-13T08:51:48.262661+00:00", + "updated_on": "2026-01-12T12:47:41.900846+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9030": "Clayton Best", + "field_9031": "clayton.best@example.com", + "field_9032": "(916) 478-0153", + "field_9033": [ + 6 + ] + }, + { + "id": 5, + "order": "5.00000000000000000000", + "created_on": "2025-11-13T08:51:48.262694+00:00", + "updated_on": "2026-01-12T12:47:41.900872+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9030": "Donald Johns", + "field_9031": "donald.johns@example.com", + "field_9032": "(805) 367-1199", + "field_9033": [ + 7 + ] + } + ], + "data_sync": null, + "field_rules": [] + }, + { + "id": 964, + "name": "Markets", + "order": 2, + "fields": [ + { + "id": 9034, + "type": "text", + "name": "Market Name", + "description": null, + "order": 0, + "primary": true, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9035, + "type": "link_row", + "name": "Campaigns", + "description": null, + "order": 3, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 967, + "link_row_related_field_id": 9052, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": true + }, + { + "id": 9081, + "type": "count", + "name": "Number of campaigns", + "description": null, + "order": 4, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9035 + } + ], + "views": [ + { + "id": 4412, + "type": "grid", + "name": "All markets", + "order": 1, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36655, + "field_id": 9034, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36656, + "field_id": 9035, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36657, + "field_id": 9081, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + } + ], + "rows": [ + { + "id": 1, + "order": "1.00000000000000000000", + "created_on": "2025-11-13T09:20:45.508112+00:00", + "updated_on": "2025-11-13T09:24:56.541584+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9034": "DACH", + "field_9035": [ + 3, + 4 + ], + "field_9081": null + }, + { + "id": 2, + "order": "2.00000000000000000000", + "created_on": "2025-11-13T09:20:45.508194+00:00", + "updated_on": "2025-11-13T09:25:04.824770+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9034": "Southeast Asia", + "field_9035": [ + 7, + 5 + ], + "field_9081": null + }, + { + "id": 3, + "order": "3.00000000000000000000", + "created_on": "2025-11-13T09:20:45.508237+00:00", + "updated_on": "2025-11-13T09:25:11.189463+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9034": "Latin America", + "field_9035": [ + 6 + ], + "field_9081": null + }, + { + "id": 4, + "order": "4.00000000000000000000", + "created_on": "2025-11-13T09:20:45.508278+00:00", + "updated_on": "2025-11-13T09:25:15.081576+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9034": "East Africa", + "field_9035": [], + "field_9081": null + }, + { + "id": 5, + "order": "5.00000000000000000000", + "created_on": "2025-11-13T09:20:45.508319+00:00", + "updated_on": "2025-11-13T09:25:19.925573+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9034": "Oceania", + "field_9035": [], + "field_9081": null + } + ], + "data_sync": null, + "field_rules": [] + }, + { + "id": 965, + "name": "Brands", + "order": 3, + "fields": [ + { + "id": 9036, + "type": "text", + "name": "Name", + "description": null, + "order": 1, + "primary": true, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9038, + "type": "text", + "name": "Description", + "description": null, + "order": 2, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9039, + "type": "link_row", + "name": "Campaigns", + "description": null, + "order": 3, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 967, + "link_row_related_field_id": 9045, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": true + }, + { + "id": 9082, + "type": "count", + "name": "Number of campaigns", + "description": null, + "order": 4, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9039 + } + ], + "views": [ + { + "id": 4413, + "type": "grid", + "name": "All brands", + "order": 1, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36659, + "field_id": 9036, + "width": 135, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36660, + "field_id": 9038, + "width": 467, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36661, + "field_id": 9039, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36662, + "field_id": 9082, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + } + ], + "rows": [ + { + "id": 1, + "order": "1.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443590+00:00", + "updated_on": "2026-01-12T12:49:54.918830+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Brewvella", + "field_9038": "A worldwide brand of instant coffee and related products.", + "field_9039": [ + 3 + ], + "field_9082": null + }, + { + "id": 2, + "order": "2.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443641+00:00", + "updated_on": "2026-01-12T12:49:54.918909+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Waforio", + "field_9038": "A chocolate-covered wafer bar confection produced internationally.", + "field_9039": [ + 4 + ], + "field_9082": null + }, + { + "id": 3, + "order": "3.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443650+00:00", + "updated_on": "2026-01-12T12:49:54.918946+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Savoryx", + "field_9038": "A brand of seasonings, instant soups, and noodles.", + "field_9039": [ + 5 + ], + "field_9082": null + }, + { + "id": 4, + "order": "4.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443656+00:00", + "updated_on": "2026-01-12T12:49:54.918971+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Aquaryth", + "field_9038": "A popular bottled water brand sold globally.", + "field_9039": [ + 6 + ], + "field_9082": null + }, + { + "id": 5, + "order": "5.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443662+00:00", + "updated_on": "2026-01-12T12:49:54.918996+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Podesso", + "field_9038": "A brand of coffee machines and coffee pods.", + "field_9039": [ + 7 + ], + "field_9082": null + }, + { + "id": 6, + "order": "6.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443671+00:00", + "updated_on": "2026-01-12T12:49:54.919020+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Chocmaltia", + "field_9038": "A chocolate and malt powder drink with milk or water.", + "field_9039": [], + "field_9082": null + }, + { + "id": 7, + "order": "7.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443681+00:00", + "updated_on": "2026-01-12T12:49:54.919044+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Milkara", + "field_9038": "A brand associated with evaporated milk and other products.", + "field_9039": [], + "field_9082": null + }, + { + "id": 8, + "order": "8.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443687+00:00", + "updated_on": "2026-01-12T12:49:54.919068+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Coloribbles", + "field_9038": "Colorful sugar-coated chocolate confectionery.", + "field_9039": [], + "field_9082": null + }, + { + "id": 9, + "order": "9.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443692+00:00", + "updated_on": "2026-01-12T12:49:54.919092+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Creamyra", + "field_9038": "Non-dairy creamer product for coffee.", + "field_9039": [], + "field_9082": null + }, + { + "id": 10, + "order": "10.00000000000000000000", + "created_on": "2025-11-13T09:02:48.443699+00:00", + "updated_on": "2026-01-12T12:49:54.919116+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9036": "Babyvera", + "field_9038": "A well-known brand for baby foods and products.", + "field_9039": [], + "field_9082": null + } + ], + "data_sync": null, + "field_rules": [] + }, + { + "id": 966, + "name": "KPI", + "order": 4, + "fields": [ + { + "id": 9040, + "type": "text", + "name": "Name", + "description": null, + "order": 0, + "primary": true, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9041, + "type": "text", + "name": "Description", + "description": null, + "order": 1, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9042, + "type": "link_row", + "name": "Primary", + "description": null, + "order": 2, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 968, + "link_row_related_field_id": 9055, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": true + }, + { + "id": 9043, + "type": "link_row", + "name": "Secondary", + "description": null, + "order": 3, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 968, + "link_row_related_field_id": 9056, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": true + }, + { + "id": 9083, + "type": "count", + "name": "Used as primary KPI", + "description": null, + "order": 4, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9042 + }, + { + "id": 9084, + "type": "count", + "name": "Used as secondary KPI", + "description": null, + "order": 5, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9043 + }, + { + "id": 9099, + "type": "formula", + "name": "Total usage", + "description": null, + "order": 6, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "formula": "field('Used as primary KPI') + field('Used as secondary KPI')", + "formula_type": "number" + } + ], + "views": [ + { + "id": 4414, + "type": "grid", + "name": "All KPIs", + "order": 1, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36663, + "field_id": 9040, + "width": 200, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36664, + "field_id": 9041, + "width": 420, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36665, + "field_id": 9042, + "width": 200, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36666, + "field_id": 9043, + "width": 264, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36667, + "field_id": 9083, + "width": 200, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36668, + "field_id": 9084, + "width": 200, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36669, + "field_id": 9099, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4445, + "type": "grid", + "name": "KPIs used in more than 3 experiments", + "order": 2, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [ + { + "id": 2661, + "field_id": 9099, + "type": "higher_than", + "value": "3", + "group": null + } + ], + "filter_groups": [], + "sortings": [ + { + "id": 2895, + "field_id": 9099, + "order": "DESC" + } + ], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 37068, + "field_id": 9040, + "width": 200, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37069, + "field_id": 9041, + "width": 420, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37070, + "field_id": 9042, + "width": 200, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37071, + "field_id": 9043, + "width": 264, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37072, + "field_id": 9083, + "width": 200, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37073, + "field_id": 9084, + "width": 200, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37074, + "field_id": 9099, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + } + ], + "rows": [ + { + "id": 1, + "order": "1.00000000000000000000", + "created_on": "2025-11-13T08:46:30.151616+00:00", + "updated_on": "2025-11-13T08:48:51.211150+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9040": "Click-Through rate", + "field_9041": "Percentage of users who clicked on a link or ad.", + "field_9042": [ + 3 + ], + "field_9043": [ + 7 + ], + "field_9083": null, + "field_9084": null, + "field_9099": null + }, + { + "id": 2, + "order": "2.00000000000000000000", + "created_on": "2025-11-13T08:46:30.151718+00:00", + "updated_on": "2025-11-13T08:48:51.211265+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9040": "Conversion rate", + "field_9041": "Percentage of visits that result in a desired action.", + "field_9042": [ + 4, + 7 + ], + "field_9043": [ + 3, + 6 + ], + "field_9083": null, + "field_9084": null, + "field_9099": null + }, + { + "id": 3, + "order": "3.00000000000000000000", + "created_on": "2025-11-13T08:47:05.488219+00:00", + "updated_on": "2025-11-13T08:48:51.211318+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9040": "Add-to-cart rate", + "field_9041": "Percent of visitors adding at least one item to cart.", + "field_9042": [ + 6 + ], + "field_9043": [], + "field_9083": null, + "field_9084": null, + "field_9099": null + }, + { + "id": 4, + "order": "4.00000000000000000000", + "created_on": "2025-11-13T08:47:18.957677+00:00", + "updated_on": "2025-11-13T08:48:51.211344+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9040": "Bounce rate", + "field_9041": "Percent of visitors who leave after viewing only one page.", + "field_9042": [], + "field_9043": [ + 4 + ], + "field_9083": null, + "field_9084": null, + "field_9099": null + }, + { + "id": 5, + "order": "5.00000000000000000000", + "created_on": "2025-11-13T08:47:31.445716+00:00", + "updated_on": "2025-11-13T08:48:51.211371+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9040": "Scroll depth", + "field_9041": "How far down a page users scroll, on average.", + "field_9042": [ + 5 + ], + "field_9043": [ + 5 + ], + "field_9083": null, + "field_9084": null, + "field_9099": null + }, + { + "id": 6, + "order": "6.00000000000000000000", + "created_on": "2025-11-13T08:47:37.930138+00:00", + "updated_on": "2025-11-13T08:48:51.211397+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9040": "Time on page", + "field_9041": "Total time a user spends on a single page.", + "field_9042": [], + "field_9043": [], + "field_9083": null, + "field_9084": null, + "field_9099": null + }, + { + "id": 7, + "order": "7.00000000000000000000", + "created_on": "2025-11-13T08:47:41.702558+00:00", + "updated_on": "2025-11-13T08:48:51.211422+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9040": "Average time on page", + "field_9041": "The average time users spend on a page.", + "field_9042": [], + "field_9043": [], + "field_9083": null, + "field_9084": null, + "field_9099": null + }, + { + "id": 8, + "order": "8.00000000000000000000", + "created_on": "2025-11-13T08:47:51.076047+00:00", + "updated_on": "2025-11-13T08:48:51.211448+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9040": "Abandonment rate", + "field_9041": "Percentage of users who abandon before completing a process.", + "field_9042": [], + "field_9043": [], + "field_9083": null, + "field_9084": null, + "field_9099": null + }, + { + "id": 9, + "order": "9.00000000000000000000", + "created_on": "2025-11-13T08:47:58.005069+00:00", + "updated_on": "2025-11-13T08:48:51.211472+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9040": "Session duration", + "field_9041": "How long a session lasts from start to end.", + "field_9042": [], + "field_9043": [], + "field_9083": null, + "field_9084": null, + "field_9099": null + } + ], + "data_sync": null, + "field_rules": [] + }, + { + "id": 967, + "name": "Campaigns", + "order": 5, + "fields": [ + { + "id": 9044, + "type": "text", + "name": "Name", + "description": null, + "order": 0, + "primary": true, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9045, + "type": "link_row", + "name": "Brand", + "description": null, + "order": 1, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 965, + "link_row_related_field_id": 9039, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": true + }, + { + "id": 9046, + "type": "text", + "name": "Objective", + "description": null, + "order": 2, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9047, + "type": "date", + "name": "Start Date", + "description": null, + "order": 3, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_format": "EU", + "date_include_time": false, + "date_time_format": "24", + "date_show_tzinfo": false, + "date_force_timezone": null + }, + { + "id": 9048, + "type": "date", + "name": "End Date", + "description": null, + "order": 4, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_format": "EU", + "date_include_time": false, + "date_time_format": "24", + "date_show_tzinfo": false, + "date_force_timezone": null + }, + { + "id": 9049, + "type": "number", + "name": "Budget", + "description": null, + "order": 5, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "number_decimal_places": 0, + "number_negative": false, + "number_prefix": "", + "number_suffix": "EUR", + "number_separator": "", + "number_default": null + }, + { + "id": 9050, + "type": "long_text", + "name": "Description", + "description": null, + "order": 7, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "long_text_enable_rich_text": false + }, + { + "id": 9051, + "type": "link_row", + "name": "Experiments", + "description": null, + "order": 8, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 968, + "link_row_related_field_id": 9061, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": true + }, + { + "id": 9052, + "type": "link_row", + "name": "Market", + "description": null, + "order": 9, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 964, + "link_row_related_field_id": 9035, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": false + }, + { + "id": 9085, + "type": "count", + "name": "Number of experiments", + "description": null, + "order": 10, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9051 + } + ], + "views": [ + { + "id": 4415, + "type": "grid", + "name": "All campaigns", + "order": 1, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [ + { + "id": 2883, + "field_id": 9047, + "order": "DESC" + } + ], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36670, + "field_id": 9044, + "width": 284, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36671, + "field_id": 9050, + "width": 777, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36672, + "field_id": 9045, + "width": 183, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36673, + "field_id": 9052, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36674, + "field_id": 9046, + "width": 310, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36675, + "field_id": 9047, + "width": 131, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36676, + "field_id": 9048, + "width": 117, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36677, + "field_id": 9049, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36678, + "field_id": 9051, + "width": 200, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36679, + "field_id": 9085, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4416, + "type": "grid", + "name": "All campaigns grouped by brand", + "order": 2, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [ + { + "id": 2884, + "field_id": 9047, + "order": "DESC" + } + ], + "group_bys": [ + { + "id": 481, + "field_id": 9045, + "order": "ASC" + } + ], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36680, + "field_id": 9044, + "width": 284, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36681, + "field_id": 9050, + "width": 311, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36682, + "field_id": 9045, + "width": 183, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36683, + "field_id": 9052, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36684, + "field_id": 9046, + "width": 310, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36685, + "field_id": 9047, + "width": 131, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36686, + "field_id": 9048, + "width": 117, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36687, + "field_id": 9049, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36688, + "field_id": 9051, + "width": 200, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36689, + "field_id": 9085, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4417, + "type": "grid", + "name": "All campaigns grouped by market", + "order": 3, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [ + { + "id": 2885, + "field_id": 9047, + "order": "DESC" + } + ], + "group_bys": [ + { + "id": 482, + "field_id": 9052, + "order": "ASC" + } + ], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36690, + "field_id": 9044, + "width": 284, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36691, + "field_id": 9050, + "width": 311, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36692, + "field_id": 9045, + "width": 183, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36693, + "field_id": 9052, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36694, + "field_id": 9046, + "width": 310, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36695, + "field_id": 9047, + "width": 131, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36696, + "field_id": 9048, + "width": 117, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36697, + "field_id": 9049, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36698, + "field_id": 9051, + "width": 200, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36699, + "field_id": 9085, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4418, + "type": "timeline", + "name": "Timeline: all campaigns", + "order": 4, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [ + { + "id": 2886, + "field_id": 9047, + "order": "DESC" + } + ], + "decorations": [], + "public": false, + "start_date_field_id": 9047, + "end_date_field_id": 9048, + "field_options": [ + { + "id": 385, + "field_id": 9044, + "hidden": false, + "order": 32767 + }, + { + "id": 386, + "field_id": 9045, + "hidden": true, + "order": 32767 + }, + { + "id": 387, + "field_id": 9046, + "hidden": true, + "order": 32767 + }, + { + "id": 388, + "field_id": 9047, + "hidden": true, + "order": 32767 + }, + { + "id": 389, + "field_id": 9048, + "hidden": true, + "order": 32767 + }, + { + "id": 390, + "field_id": 9049, + "hidden": true, + "order": 32767 + }, + { + "id": 391, + "field_id": 9050, + "hidden": true, + "order": 32767 + }, + { + "id": 392, + "field_id": 9051, + "hidden": true, + "order": 32767 + }, + { + "id": 393, + "field_id": 9052, + "hidden": true, + "order": 32767 + }, + { + "id": 394, + "field_id": 9085, + "hidden": true, + "order": 32767 + } + ] + }, + { + "id": 4419, + "type": "form", + "name": "Campaign Form", + "order": 5, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "public": false, + "title": "Campaign Form", + "description": "", + "cover_image": null, + "logo_image": null, + "submit_text": "Submit", + "submit_action": "MESSAGE", + "submit_action_message": "", + "submit_action_redirect_url": "", + "field_options": [ + { + "id": 3281, + "field_id": 9044, + "name": "", + "description": "", + "enabled": true, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3282, + "field_id": 9045, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3283, + "field_id": 9046, + "name": "", + "description": "", + "enabled": true, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3284, + "field_id": 9047, + "name": "", + "description": "", + "enabled": true, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3285, + "field_id": 9048, + "name": "", + "description": "", + "enabled": true, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3286, + "field_id": 9049, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3287, + "field_id": 9050, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3288, + "field_id": 9051, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3289, + "field_id": 9052, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3290, + "field_id": 9085, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + } + ] + } + ], + "rows": [ + { + "id": 3, + "order": "3.00000000000000000000", + "created_on": "2025-11-13T09:06:46.944310+00:00", + "updated_on": "2026-01-12T12:50:20.337419+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9044": "Awaken Your Senses with Brewvella", + "field_9045": [ + 1 + ], + "field_9046": "Increase brand awareness among young adults", + "field_9047": "2025-10-06", + "field_9048": "2025-12-11", + "field_9049": "100000", + "field_9050": "A multi-channel digital campaign targeting coffee lovers with influencer partnerships and interactive social content.", + "field_9051": [ + 3 + ], + "field_9052": [ + 1 + ], + "field_9085": null + }, + { + "id": 4, + "order": "4.00000000000000000000", + "created_on": "2025-11-13T09:06:46.944545+00:00", + "updated_on": "2026-01-12T12:50:31.017652+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9044": "Waforio Break Challenge", + "field_9045": [ + 2 + ], + "field_9046": "Boost product trials and social buzz", + "field_9047": "2025-07-01", + "field_9048": "2025-10-09", + "field_9049": "80000", + "field_9050": "Encourages consumers to share their #HaveABreak moments on social media, with weekly prizes for best entries.", + "field_9051": [ + 4 + ], + "field_9052": [ + 1 + ], + "field_9085": null + }, + { + "id": 5, + "order": "5.00000000000000000000", + "created_on": "2025-11-13T09:06:46.944628+00:00", + "updated_on": "2026-01-12T12:50:14.715896+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9044": "Savoryx Family Meal Drive", + "field_9045": [ + 3 + ], + "field_9046": "Grow penetration in family households", + "field_9047": "2025-10-21", + "field_9048": "2026-01-20", + "field_9049": "120000", + "field_9050": "Cross-platform promotions including in-store demos and digital recipe campaigns targeting busy parents.", + "field_9051": [ + 5 + ], + "field_9052": [ + 2 + ], + "field_9085": null + }, + { + "id": 6, + "order": "6.00000000000000000000", + "created_on": "2025-11-13T09:06:46.944680+00:00", + "updated_on": "2026-01-12T12:50:35.940211+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9044": "Aquaryth Pure Life Summer Hydration", + "field_9045": [ + 4 + ], + "field_9046": "Promote hydration awareness during summer", + "field_9047": "2025-06-10", + "field_9048": "2025-08-20", + "field_9049": "70000", + "field_9050": "Sampling events at public parks and social media education series to emphasize daily water intake.", + "field_9051": [ + 6 + ], + "field_9052": [ + 3 + ], + "field_9085": null + }, + { + "id": 7, + "order": "7.00000000000000000000", + "created_on": "2025-11-13T09:06:46.944749+00:00", + "updated_on": "2026-01-12T12:50:25.108177+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9044": "Podesso Ultimate Taste Experience", + "field_9045": [ + 5 + ], + "field_9046": "Drive sales of new premium capsules", + "field_9047": "2025-09-01", + "field_9048": "2025-10-28", + "field_9049": "150000", + "field_9050": "Exclusive pop-up tasting events in urban areas to showcase the latest Nespresso capsule innovations.", + "field_9051": [ + 7 + ], + "field_9052": [ + 2 + ], + "field_9085": null + } + ], + "data_sync": null, + "field_rules": [] + }, + { + "id": 968, + "name": "Experiments", + "order": 6, + "fields": [ + { + "id": 9053, + "type": "text", + "name": "Name", + "description": null, + "order": 0, + "primary": true, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9054, + "type": "long_text", + "name": "Hypothesis", + "description": null, + "order": 1, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "long_text_enable_rich_text": false + }, + { + "id": 9055, + "type": "link_row", + "name": "Primary KPI", + "description": null, + "order": 2, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 966, + "link_row_related_field_id": 9042, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": false + }, + { + "id": 9056, + "type": "link_row", + "name": "Secondary KPI", + "description": null, + "order": 3, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 966, + "link_row_related_field_id": 9043, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": false + }, + { + "id": 9057, + "type": "date", + "name": "Start", + "description": null, + "order": 4, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_format": "EU", + "date_include_time": false, + "date_time_format": "24", + "date_show_tzinfo": false, + "date_force_timezone": null + }, + { + "id": 9058, + "type": "date", + "name": "End", + "description": null, + "order": 5, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_format": "EU", + "date_include_time": false, + "date_time_format": "24", + "date_show_tzinfo": false, + "date_force_timezone": null + }, + { + "id": 9059, + "type": "link_row", + "name": "Owner", + "description": null, + "order": 6, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 963, + "link_row_related_field_id": 9033, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": false + }, + { + "id": 9060, + "type": "single_select", + "name": "Status", + "description": null, + "order": 7, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "select_options": [ + { + "id": 3498, + "value": "Scheduled", + "color": "dark-blue", + "order": 0 + }, + { + "id": 3499, + "value": "In progress", + "color": "dark-green", + "order": 1 + }, + { + "id": 3500, + "value": "Completed", + "color": "dark-cyan", + "order": 2 + }, + { + "id": 3501, + "value": "Archived", + "color": "dark-brown", + "order": 3 + } + ], + "single_select_default": 3498 + }, + { + "id": 9061, + "type": "link_row", + "name": "Campaign", + "description": null, + "order": 8, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 967, + "link_row_related_field_id": 9051, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": false + }, + { + "id": 9062, + "type": "link_row", + "name": "Variants", + "description": null, + "order": 9, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 969, + "link_row_related_field_id": 9067, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": true + }, + { + "id": 9086, + "type": "rollup", + "name": "Market", + "description": null, + "order": 10, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9061, + "target_field_id": 9052, + "rollup_function": "min" + }, + { + "id": 9087, + "type": "rollup", + "name": "Brand", + "description": null, + "order": 11, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9061, + "target_field_id": 9045, + "rollup_function": "min" + }, + { + "id": 9088, + "type": "rollup", + "name": "Objective", + "description": null, + "order": 12, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9061, + "target_field_id": 9046, + "rollup_function": "min" + }, + { + "id": 9063, + "type": "single_select", + "name": "Priority", + "description": null, + "order": 13, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "select_options": [ + { + "id": 3502, + "value": "Low", + "color": "light-brown", + "order": 0 + }, + { + "id": 3503, + "value": "Medium", + "color": "brown", + "order": 1 + }, + { + "id": 3504, + "value": "High", + "color": "dark-brown", + "order": 2 + }, + { + "id": 3505, + "value": "Crucial", + "color": "darker-brown", + "order": 3 + } + ], + "single_select_default": null + }, + { + "id": 9089, + "type": "count", + "name": "Number of variants", + "description": null, + "order": 14, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9062 + }, + { + "id": 9090, + "type": "formula", + "name": "Live variants count", + "description": null, + "order": 15, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "formula": "sum(if(lookup('Variants', 'Build status') = 'Live', 1, 0))", + "formula_type": "number" + }, + { + "id": 9091, + "type": "formula", + "name": "Owner Email", + "description": null, + "order": 16, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "formula": "join(lookup('Owner', 'Email'), '')", + "formula_type": "text" + }, + { + "id": 9092, + "type": "formula", + "name": "Duration", + "description": null, + "order": 17, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": "h:mm", + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "formula": "field('End') - field('Start')", + "formula_type": "duration" + } + ], + "views": [ + { + "id": 4420, + "type": "grid", + "name": "All experiments", + "order": 1, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [ + { + "id": 2887, + "field_id": 9057, + "order": "DESC" + } + ], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36700, + "field_id": 9053, + "width": 278, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36701, + "field_id": 9061, + "width": 200, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36702, + "field_id": 9088, + "width": 322, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36703, + "field_id": 9086, + "width": 154, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36704, + "field_id": 9087, + "width": 152, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36705, + "field_id": 9054, + "width": 368, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36706, + "field_id": 9055, + "width": 150, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36707, + "field_id": 9056, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36708, + "field_id": 9060, + "width": 200, + "hidden": false, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36709, + "field_id": 9063, + "width": 200, + "hidden": false, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36710, + "field_id": 9057, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36711, + "field_id": 9058, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36712, + "field_id": 9059, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36713, + "field_id": 9062, + "width": 200, + "hidden": true, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36714, + "field_id": 9089, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36715, + "field_id": 9090, + "width": 200, + "hidden": true, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36716, + "field_id": 9091, + "width": 200, + "hidden": true, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36717, + "field_id": 9092, + "width": 200, + "hidden": true, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4421, + "type": "grid", + "name": "Running experiments", + "order": 2, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [ + { + "id": 2650, + "field_id": 9060, + "type": "single_select_equal", + "value": "3499", + "group": null + } + ], + "filter_groups": [], + "sortings": [ + { + "id": 2888, + "field_id": 9057, + "order": "DESC" + } + ], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36718, + "field_id": 9053, + "width": 258, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36719, + "field_id": 9061, + "width": 200, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36720, + "field_id": 9088, + "width": 322, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36721, + "field_id": 9086, + "width": 154, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36722, + "field_id": 9087, + "width": 152, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36723, + "field_id": 9054, + "width": 368, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36724, + "field_id": 9055, + "width": 150, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36725, + "field_id": 9056, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36726, + "field_id": 9060, + "width": 200, + "hidden": false, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36727, + "field_id": 9063, + "width": 200, + "hidden": false, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36728, + "field_id": 9057, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36729, + "field_id": 9058, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36730, + "field_id": 9059, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36731, + "field_id": 9062, + "width": 200, + "hidden": true, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36732, + "field_id": 9089, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4422, + "type": "grid", + "name": "All experiments grouped by campaign", + "order": 3, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [ + { + "id": 2889, + "field_id": 9057, + "order": "DESC" + } + ], + "group_bys": [ + { + "id": 483, + "field_id": 9061, + "order": "ASC" + } + ], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36733, + "field_id": 9053, + "width": 258, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36734, + "field_id": 9061, + "width": 200, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36735, + "field_id": 9088, + "width": 322, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36736, + "field_id": 9086, + "width": 154, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36737, + "field_id": 9087, + "width": 152, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36738, + "field_id": 9054, + "width": 368, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36739, + "field_id": 9055, + "width": 150, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36740, + "field_id": 9056, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36741, + "field_id": 9060, + "width": 200, + "hidden": false, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36742, + "field_id": 9063, + "width": 200, + "hidden": false, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36743, + "field_id": 9057, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36744, + "field_id": 9058, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36745, + "field_id": 9059, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36746, + "field_id": 9062, + "width": 200, + "hidden": true, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36747, + "field_id": 9089, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4423, + "type": "grid", + "name": "All experiments grouped by market", + "order": 4, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [ + { + "id": 2890, + "field_id": 9057, + "order": "DESC" + } + ], + "group_bys": [ + { + "id": 484, + "field_id": 9086, + "order": "ASC" + } + ], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36748, + "field_id": 9053, + "width": 258, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36749, + "field_id": 9061, + "width": 200, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36750, + "field_id": 9088, + "width": 322, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36751, + "field_id": 9086, + "width": 154, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36752, + "field_id": 9087, + "width": 152, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36753, + "field_id": 9054, + "width": 368, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36754, + "field_id": 9055, + "width": 150, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36755, + "field_id": 9056, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36756, + "field_id": 9060, + "width": 200, + "hidden": false, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36757, + "field_id": 9063, + "width": 200, + "hidden": false, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36758, + "field_id": 9057, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36759, + "field_id": 9058, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36760, + "field_id": 9059, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36761, + "field_id": 9062, + "width": 200, + "hidden": true, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36762, + "field_id": 9089, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4424, + "type": "grid", + "name": "All experiments grouped by brand", + "order": 5, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [ + { + "id": 2891, + "field_id": 9057, + "order": "DESC" + } + ], + "group_bys": [ + { + "id": 485, + "field_id": 9087, + "order": "ASC" + } + ], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36763, + "field_id": 9053, + "width": 258, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36764, + "field_id": 9061, + "width": 200, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36765, + "field_id": 9088, + "width": 322, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36766, + "field_id": 9086, + "width": 154, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36767, + "field_id": 9087, + "width": 152, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36768, + "field_id": 9054, + "width": 368, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36769, + "field_id": 9055, + "width": 150, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36770, + "field_id": 9056, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36771, + "field_id": 9060, + "width": 200, + "hidden": false, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36772, + "field_id": 9063, + "width": 200, + "hidden": false, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36773, + "field_id": 9057, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36774, + "field_id": 9058, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36775, + "field_id": 9059, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36776, + "field_id": 9062, + "width": 200, + "hidden": true, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36777, + "field_id": 9089, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4425, + "type": "kanban", + "name": "All experiments stacked by status", + "order": 6, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "decorations": [], + "public": false, + "single_select_field_id": 9060, + "field_options": [ + { + "id": 5950, + "field_id": 9053, + "hidden": false, + "order": 32767 + }, + { + "id": 5951, + "field_id": 9054, + "hidden": false, + "order": 32767 + }, + { + "id": 5952, + "field_id": 9055, + "hidden": false, + "order": 32767 + }, + { + "id": 5953, + "field_id": 9056, + "hidden": false, + "order": 32767 + }, + { + "id": 5954, + "field_id": 9057, + "hidden": false, + "order": 32767 + }, + { + "id": 5955, + "field_id": 9058, + "hidden": false, + "order": 32767 + }, + { + "id": 5956, + "field_id": 9059, + "hidden": false, + "order": 32767 + }, + { + "id": 5957, + "field_id": 9060, + "hidden": true, + "order": 32767 + }, + { + "id": 5958, + "field_id": 9061, + "hidden": false, + "order": 32767 + }, + { + "id": 5959, + "field_id": 9062, + "hidden": true, + "order": 32767 + }, + { + "id": 5963, + "field_id": 9063, + "hidden": false, + "order": 32767 + }, + { + "id": 5960, + "field_id": 9086, + "hidden": false, + "order": 32767 + }, + { + "id": 5961, + "field_id": 9087, + "hidden": false, + "order": 32767 + }, + { + "id": 5962, + "field_id": 9088, + "hidden": true, + "order": 32767 + }, + { + "id": 5964, + "field_id": 9089, + "hidden": false, + "order": 32767 + } + ] + }, + { + "id": 4426, + "type": "kanban", + "name": "All experiments stacked by priority", + "order": 7, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "decorations": [], + "public": false, + "single_select_field_id": 9063, + "field_options": [ + { + "id": 5965, + "field_id": 9053, + "hidden": false, + "order": 32767 + }, + { + "id": 5966, + "field_id": 9054, + "hidden": false, + "order": 32767 + }, + { + "id": 5967, + "field_id": 9055, + "hidden": false, + "order": 32767 + }, + { + "id": 5968, + "field_id": 9056, + "hidden": false, + "order": 32767 + }, + { + "id": 5969, + "field_id": 9057, + "hidden": false, + "order": 32767 + }, + { + "id": 5970, + "field_id": 9058, + "hidden": false, + "order": 32767 + }, + { + "id": 5971, + "field_id": 9059, + "hidden": false, + "order": 32767 + }, + { + "id": 5972, + "field_id": 9060, + "hidden": false, + "order": 32767 + }, + { + "id": 5973, + "field_id": 9061, + "hidden": false, + "order": 32767 + }, + { + "id": 5974, + "field_id": 9062, + "hidden": true, + "order": 32767 + }, + { + "id": 5978, + "field_id": 9063, + "hidden": true, + "order": 32767 + }, + { + "id": 5975, + "field_id": 9086, + "hidden": false, + "order": 32767 + }, + { + "id": 5976, + "field_id": 9087, + "hidden": false, + "order": 32767 + }, + { + "id": 5977, + "field_id": 9088, + "hidden": true, + "order": 32767 + }, + { + "id": 5979, + "field_id": 9089, + "hidden": false, + "order": 32767 + } + ] + } + ], + "rows": [ + { + "id": 3, + "order": "3.00000000000000000000", + "created_on": "2025-11-13T09:18:11.664235+00:00", + "updated_on": "2026-01-12T13:39:25.499515+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9053": "Brewvalle Instagram Stories Test", + "field_9054": "Short video stories will boost engagement with Gen Z coffee consumers.", + "field_9055": [ + 1 + ], + "field_9056": [ + 2 + ], + "field_9057": "2025-09-10", + "field_9058": "2025-11-20", + "field_9059": [ + 1 + ], + "field_9060": 3499, + "field_9061": [ + 3 + ], + "field_9062": [ + 3, + 4, + 5, + 6, + 7 + ], + "field_9086": null, + "field_9087": null, + "field_9088": null, + "field_9063": 3502, + "field_9089": null, + "field_9090": null, + "field_9091": null, + "field_9092": null + }, + { + "id": 4, + "order": "4.00000000000000000000", + "created_on": "2025-11-13T09:18:11.664415+00:00", + "updated_on": "2026-01-12T12:51:23.959647+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9053": "Waforio Hashtag Participation Incentive", + "field_9054": "Offering prizes will double #HaveABreak challenge participation.", + "field_9055": [ + 2 + ], + "field_9056": [ + 4 + ], + "field_9057": "2025-07-01", + "field_9058": "2025-07-21", + "field_9059": [ + 2 + ], + "field_9060": 3500, + "field_9061": [ + 4 + ], + "field_9062": [ + 8, + 9, + 10, + 11, + 12 + ], + "field_9086": null, + "field_9087": null, + "field_9088": null, + "field_9063": 3503, + "field_9089": null, + "field_9090": null, + "field_9091": null, + "field_9092": null + }, + { + "id": 5, + "order": "5.00000000000000000000", + "created_on": "2025-11-13T09:18:11.664471+00:00", + "updated_on": "2026-01-12T12:52:45.036258+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9053": "Savoryx Recipe Video A/B", + "field_9054": "In-feed videos outperform static images for meal inspiration engagement.", + "field_9055": [ + 5 + ], + "field_9056": [ + 5 + ], + "field_9057": "2025-10-23", + "field_9058": "2025-10-14", + "field_9059": [ + 3 + ], + "field_9060": 3499, + "field_9061": [ + 5 + ], + "field_9062": [ + 13, + 14, + 15, + 16, + 17 + ], + "field_9086": null, + "field_9087": null, + "field_9088": null, + "field_9063": 3503, + "field_9089": null, + "field_9090": null, + "field_9091": null, + "field_9092": null + }, + { + "id": 6, + "order": "6.00000000000000000000", + "created_on": "2025-11-13T09:18:11.664520+00:00", + "updated_on": "2025-11-13T09:39:28.259714+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9053": "Pure Life Park Sampling Impact", + "field_9054": "On-site sampling will increase local store purchases within 2 weeks.", + "field_9055": [ + 3 + ], + "field_9056": [ + 2 + ], + "field_9057": "2025-06-10", + "field_9058": "2025-06-20", + "field_9059": [ + 4 + ], + "field_9060": 3500, + "field_9061": [ + 6 + ], + "field_9062": [ + 18, + 19, + 20, + 21, + 22 + ], + "field_9086": null, + "field_9087": null, + "field_9088": null, + "field_9063": 3504, + "field_9089": null, + "field_9090": null, + "field_9091": null, + "field_9092": null + }, + { + "id": 7, + "order": "7.00000000000000000000", + "created_on": "2025-11-13T09:18:11.664567+00:00", + "updated_on": "2026-01-12T12:51:18.370012+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9053": "Podesso Capsule Tasting Signup Lift", + "field_9054": "Offering exclusive tasting previews will drive more newsletter signups.", + "field_9055": [ + 2 + ], + "field_9056": [ + 1 + ], + "field_9057": "2025-09-01", + "field_9058": "2025-09-15", + "field_9059": [ + 5 + ], + "field_9060": 3498, + "field_9061": [ + 7 + ], + "field_9062": [ + 23, + 25, + 24, + 26, + 27 + ], + "field_9086": null, + "field_9087": null, + "field_9088": null, + "field_9063": 3505, + "field_9089": null, + "field_9090": null, + "field_9091": null, + "field_9092": null + } + ], + "data_sync": null, + "field_rules": [] + }, + { + "id": 969, + "name": "Variants", + "order": 7, + "fields": [ + { + "id": 9064, + "type": "text", + "name": "ID", + "description": null, + "order": 0, + "primary": true, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9065, + "type": "text", + "name": "Name", + "description": null, + "order": 1, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9066, + "type": "single_select", + "name": "Build status", + "description": null, + "order": 2, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "select_options": [ + { + "id": 3506, + "value": "Idea", + "color": "light-blue", + "order": 0 + }, + { + "id": 3507, + "value": "In Design", + "color": "blue", + "order": 1 + }, + { + "id": 3508, + "value": "In Development", + "color": "blue", + "order": 2 + }, + { + "id": 3509, + "value": "QA", + "color": "dark-blue", + "order": 3 + }, + { + "id": 3510, + "value": "Live", + "color": "darker-blue", + "order": 4 + } + ], + "single_select_default": 3506 + }, + { + "id": 9067, + "type": "link_row", + "name": "Experiment", + "description": null, + "order": 3, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "link_row_table_id": 968, + "link_row_related_field_id": 9062, + "link_row_limit_selection_view_id": null, + "has_related_field": true, + "link_row_multiple_relationships": false + }, + { + "id": 9093, + "type": "rollup", + "name": "Campaign", + "description": null, + "order": 4, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9067, + "target_field_id": 9061, + "rollup_function": "min" + }, + { + "id": 9100, + "type": "rollup", + "name": "Market", + "description": null, + "order": 5, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9067, + "target_field_id": 9086, + "rollup_function": "min" + }, + { + "id": 9101, + "type": "rollup", + "name": "Brand", + "description": null, + "order": 6, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9067, + "target_field_id": 9087, + "rollup_function": "min" + }, + { + "id": 9102, + "type": "rollup", + "name": "Objective", + "description": null, + "order": 7, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9067, + "target_field_id": 9088, + "rollup_function": "min" + }, + { + "id": 9094, + "type": "rollup", + "name": "Hypothesis", + "description": null, + "order": 8, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9067, + "target_field_id": 9054, + "rollup_function": "min" + }, + { + "id": 9095, + "type": "rollup", + "name": "Primary KPI", + "description": null, + "order": 9, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9067, + "target_field_id": 9055, + "rollup_function": "min" + }, + { + "id": 9096, + "type": "rollup", + "name": "Secondary KPI", + "description": null, + "order": 10, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": true, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "through_field_id": 9067, + "target_field_id": 9055, + "rollup_function": "min" + }, + { + "id": 9068, + "type": "text", + "name": "Test focus", + "description": null, + "order": 11, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9069, + "type": "text", + "name": "Test change", + "description": null, + "order": 12, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "text_default": "" + }, + { + "id": 9070, + "type": "long_text", + "name": "Design requirements", + "description": null, + "order": 13, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "long_text_enable_rich_text": false + }, + { + "id": 9071, + "type": "single_select", + "name": "Device", + "description": null, + "order": 14, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "select_options": [ + { + "id": 3511, + "value": "Laptop", + "color": "light-gray", + "order": 0 + }, + { + "id": 3512, + "value": "Phone", + "color": "gray", + "order": 1 + }, + { + "id": 3513, + "value": "Tablet", + "color": "gray", + "order": 2 + }, + { + "id": 3514, + "value": "TV", + "color": "dark-gray", + "order": 3 + } + ], + "single_select_default": null + }, + { + "id": 9072, + "type": "rating", + "name": "Potential (1-6)", + "description": null, + "order": 15, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "max_value": 6, + "color": "dark-orange", + "style": "star" + }, + { + "id": 9073, + "type": "single_select", + "name": "Above the fold (0-2)", + "description": null, + "order": 16, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "select_options": [ + { + "id": 3515, + "value": "0", + "color": "light-orange", + "order": 0 + }, + { + "id": 3516, + "value": "1", + "color": "light-blue", + "order": 1 + }, + { + "id": 3517, + "value": "2", + "color": "light-green", + "order": 2 + } + ], + "single_select_default": null + }, + { + "id": 9074, + "type": "rating", + "name": "Complexity (1-6)", + "description": null, + "order": 17, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "max_value": 6, + "color": "dark-orange", + "style": "star" + }, + { + "id": 9075, + "type": "single_select", + "name": "Based on previous test (0-2)", + "description": null, + "order": 18, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "select_options": [ + { + "id": 3518, + "value": "0", + "color": "light-orange", + "order": 0 + }, + { + "id": 3519, + "value": "1", + "color": "light-blue", + "order": 1 + }, + { + "id": 3520, + "value": "2", + "color": "light-green", + "order": 2 + } + ], + "single_select_default": null + }, + { + "id": 9076, + "type": "single_select", + "name": "Based on quantitative data (0-2)", + "description": null, + "order": 19, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "select_options": [ + { + "id": 3521, + "value": "0", + "color": "light-orange", + "order": 0 + }, + { + "id": 3522, + "value": "1", + "color": "light-blue", + "order": 1 + }, + { + "id": 3523, + "value": "2", + "color": "light-green", + "order": 2 + } + ], + "single_select_default": null + }, + { + "id": 9077, + "type": "single_select", + "name": "Based on qualitative data (0-2)", + "description": null, + "order": 20, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "select_options": [ + { + "id": 3524, + "value": "0", + "color": "light-orange", + "order": 0 + }, + { + "id": 3525, + "value": "1", + "color": "light-blue", + "order": 1 + }, + { + "id": 3526, + "value": "2", + "color": "light-green", + "order": 2 + } + ], + "single_select_default": null + }, + { + "id": 9078, + "type": "long_text", + "name": "Comments", + "description": null, + "order": 21, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "long_text_enable_rich_text": false + }, + { + "id": 9079, + "type": "file", + "name": "Images", + "description": null, + "order": 22, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [] + }, + { + "id": 9080, + "type": "file", + "name": "Document", + "description": null, + "order": 23, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [] + }, + { + "id": 9097, + "type": "formula", + "name": "Priority score", + "description": null, + "order": 24, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "formula": "(field('Potential (1-6)') - field('Complexity (1-6)')) + 5", + "formula_type": "number" + }, + { + "id": 9098, + "type": "formula", + "name": "Confidence score", + "description": null, + "order": 25, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 0, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "formula": "tonumber(totext(field('Above the fold (0-2)'))) + tonumber(totext(field('Based on previous test (0-2)'))) + tonumber(totext(field('Based on quantitative data (0-2)'))) + tonumber(totext(field('Based on qualitative data (0-2)')))", + "formula_type": "number" + }, + { + "id": 9103, + "type": "formula", + "name": "Opportunity index", + "description": null, + "order": 26, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": 2, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "formula": "(field('Potential (1-6)') * field('Confidence score')) / field('Complexity (1-6)')", + "formula_type": "number" + }, + { + "id": 9104, + "type": "formula", + "name": "Conclusion", + "description": null, + "order": 27, + "primary": false, + "read_only": false, + "db_index": false, + "immutable_type": false, + "immutable_properties": false, + "field_constraints": [], + "date_time_format": null, + "array_formula_type": null, + "duration_format": null, + "number_decimal_places": null, + "date_format": null, + "number_prefix": "", + "date_force_timezone": null, + "date_include_time": null, + "nullable": false, + "error": null, + "number_separator": "", + "number_suffix": "", + "date_show_tzinfo": null, + "formula": "if(field('Opportunity index') <= 10, '\ud83d\udd34 Low', if(field('Opportunity index') <= 20, '\ud83d\udfe0 Medium', if(field('Opportunity index') <= 35, '\ud83d\udfe1 High', '\ud83d\udfe2 Top Priority')))", + "formula_type": "text" + } + ], + "views": [ + { + "id": 4427, + "type": "grid", + "name": "All variants (all fields)", + "order": 1, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [ + { + "id": 1093, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36778, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36779, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36780, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36781, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36782, + "field_id": 9093, + "width": 281, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36783, + "field_id": 9102, + "width": 200, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36784, + "field_id": 9100, + "width": 200, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36785, + "field_id": 9101, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36786, + "field_id": 9094, + "width": 475, + "hidden": false, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36787, + "field_id": 9095, + "width": 200, + "hidden": false, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36788, + "field_id": 9096, + "width": 200, + "hidden": false, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36789, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36790, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36791, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36792, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36793, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36794, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36795, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36796, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36797, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36798, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 21, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36799, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 22, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36800, + "field_id": 9079, + "width": 112, + "hidden": false, + "order": 23, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36801, + "field_id": 9080, + "width": 134, + "hidden": false, + "order": 24, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36802, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 25, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36803, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 26, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36804, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 27, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36805, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 28, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4428, + "type": "grid", + "name": "All variants (rating fields)", + "order": 2, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [ + { + "id": 1094, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36806, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36807, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36808, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36809, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36810, + "field_id": 9093, + "width": 281, + "hidden": true, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36811, + "field_id": 9102, + "width": 200, + "hidden": true, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36812, + "field_id": 9100, + "width": 200, + "hidden": true, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36813, + "field_id": 9101, + "width": 200, + "hidden": true, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36814, + "field_id": 9094, + "width": 475, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36815, + "field_id": 9095, + "width": 200, + "hidden": true, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36816, + "field_id": 9096, + "width": 200, + "hidden": true, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36817, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36818, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36819, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36820, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36821, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36822, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36823, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36824, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36825, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36826, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36827, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36828, + "field_id": 9079, + "width": 200, + "hidden": true, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36829, + "field_id": 9080, + "width": 200, + "hidden": true, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36830, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36831, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36832, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36833, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4429, + "type": "grid", + "name": "Variants with complexity < 3", + "order": 3, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [ + { + "id": 2651, + "field_id": 9074, + "type": "lower_than", + "value": "3", + "group": null + } + ], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [ + { + "id": 1095, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36834, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36835, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36836, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36837, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36838, + "field_id": 9093, + "width": 281, + "hidden": true, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36839, + "field_id": 9102, + "width": 200, + "hidden": true, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36840, + "field_id": 9100, + "width": 200, + "hidden": true, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36841, + "field_id": 9101, + "width": 200, + "hidden": true, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36842, + "field_id": 9094, + "width": 475, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36843, + "field_id": 9095, + "width": 200, + "hidden": true, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36844, + "field_id": 9096, + "width": 200, + "hidden": true, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36845, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36846, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36847, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36848, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36849, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36850, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36851, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36852, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36853, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36854, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36855, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36856, + "field_id": 9079, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36857, + "field_id": 9080, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36858, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36859, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36860, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36861, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4430, + "type": "grid", + "name": "Variants with complexity >= 3", + "order": 4, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [ + { + "id": 2652, + "field_id": 9074, + "type": "higher_than_or_equal", + "value": "3", + "group": null + } + ], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [ + { + "id": 1096, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36862, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36863, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36864, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36865, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36866, + "field_id": 9093, + "width": 281, + "hidden": true, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36867, + "field_id": 9102, + "width": 200, + "hidden": true, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36868, + "field_id": 9100, + "width": 200, + "hidden": true, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36869, + "field_id": 9101, + "width": 200, + "hidden": true, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36870, + "field_id": 9094, + "width": 475, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36871, + "field_id": 9095, + "width": 200, + "hidden": true, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36872, + "field_id": 9096, + "width": 200, + "hidden": true, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36873, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36874, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36875, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36876, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36877, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36878, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36879, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36880, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36881, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36882, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36883, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36884, + "field_id": 9079, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36885, + "field_id": 9080, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36886, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36887, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36888, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36889, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4431, + "type": "grid", + "name": "Variants with potential < 3", + "order": 5, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [ + { + "id": 2653, + "field_id": 9072, + "type": "lower_than", + "value": "3", + "group": null + } + ], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [ + { + "id": 1097, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36890, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36891, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36892, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36893, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36894, + "field_id": 9093, + "width": 281, + "hidden": true, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36895, + "field_id": 9102, + "width": 200, + "hidden": true, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36896, + "field_id": 9100, + "width": 200, + "hidden": true, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36897, + "field_id": 9101, + "width": 200, + "hidden": true, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36898, + "field_id": 9094, + "width": 475, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36899, + "field_id": 9095, + "width": 200, + "hidden": true, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36900, + "field_id": 9096, + "width": 200, + "hidden": true, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36901, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36902, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36903, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36904, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36905, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36906, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36907, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36908, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36909, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36910, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36911, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36912, + "field_id": 9079, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36913, + "field_id": 9080, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36914, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36915, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36916, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36917, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4432, + "type": "grid", + "name": "Variants with potential >= 3", + "order": 6, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [ + { + "id": 2654, + "field_id": 9072, + "type": "higher_than_or_equal", + "value": "3", + "group": null + } + ], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [ + { + "id": 1098, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36918, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36919, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36920, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36921, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36922, + "field_id": 9093, + "width": 281, + "hidden": true, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36923, + "field_id": 9102, + "width": 200, + "hidden": true, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36924, + "field_id": 9100, + "width": 200, + "hidden": true, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36925, + "field_id": 9101, + "width": 200, + "hidden": true, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36926, + "field_id": 9094, + "width": 475, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36927, + "field_id": 9095, + "width": 200, + "hidden": true, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36928, + "field_id": 9096, + "width": 200, + "hidden": true, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36929, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36930, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36931, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36932, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36933, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36934, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36935, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36936, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36937, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36938, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36939, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36940, + "field_id": 9079, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36941, + "field_id": 9080, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36942, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36943, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36944, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36945, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4433, + "type": "grid", + "name": "Variants with top + high priority", + "order": 7, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "OR", + "filters_disabled": false, + "filters": [ + { + "id": 2655, + "field_id": 9104, + "type": "contains_word", + "value": "High", + "group": null + }, + { + "id": 2656, + "field_id": 9104, + "type": "contains_word", + "value": "Top", + "group": null + } + ], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [ + { + "id": 1099, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36946, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36947, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36948, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36949, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36950, + "field_id": 9093, + "width": 281, + "hidden": true, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36951, + "field_id": 9102, + "width": 200, + "hidden": true, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36952, + "field_id": 9100, + "width": 200, + "hidden": true, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36953, + "field_id": 9101, + "width": 200, + "hidden": true, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36954, + "field_id": 9094, + "width": 475, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36955, + "field_id": 9095, + "width": 200, + "hidden": true, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36956, + "field_id": 9096, + "width": 200, + "hidden": true, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36957, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36958, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36959, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36960, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36961, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36962, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36963, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36964, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36965, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36966, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36967, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36968, + "field_id": 9079, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36969, + "field_id": 9080, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36970, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36971, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36972, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36973, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4434, + "type": "grid", + "name": "Variants with top + high priority (all fields)", + "order": 8, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "OR", + "filters_disabled": false, + "filters": [ + { + "id": 2657, + "field_id": 9104, + "type": "contains_word", + "value": "High", + "group": null + }, + { + "id": 2658, + "field_id": 9104, + "type": "contains_word", + "value": "Top", + "group": null + } + ], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [ + { + "id": 1100, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 36974, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36975, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36976, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36977, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36978, + "field_id": 9093, + "width": 281, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36979, + "field_id": 9102, + "width": 200, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36980, + "field_id": 9100, + "width": 200, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36981, + "field_id": 9101, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36982, + "field_id": 9094, + "width": 475, + "hidden": false, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36983, + "field_id": 9095, + "width": 200, + "hidden": false, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36984, + "field_id": 9096, + "width": 200, + "hidden": false, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36985, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36986, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36987, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36988, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36989, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36990, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36991, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36992, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36993, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36994, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36995, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36996, + "field_id": 9079, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36997, + "field_id": 9080, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36998, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 36999, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37000, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37001, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4435, + "type": "grid", + "name": "All variants grouped by experiment", + "order": 9, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [ + { + "id": 486, + "field_id": 9067, + "order": "ASC" + } + ], + "decorations": [ + { + "id": 1101, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 37002, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37003, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37004, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37005, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37006, + "field_id": 9093, + "width": 281, + "hidden": true, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37007, + "field_id": 9102, + "width": 200, + "hidden": true, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37008, + "field_id": 9100, + "width": 200, + "hidden": true, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37009, + "field_id": 9101, + "width": 200, + "hidden": true, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37010, + "field_id": 9094, + "width": 475, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37011, + "field_id": 9095, + "width": 200, + "hidden": true, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37012, + "field_id": 9096, + "width": 200, + "hidden": true, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37013, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37014, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37015, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37016, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37017, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37018, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37019, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37020, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37021, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37022, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37023, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37024, + "field_id": 9079, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37025, + "field_id": 9080, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37026, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37027, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37028, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37029, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4436, + "type": "grid", + "name": "All variants grouped by device", + "order": 10, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [ + { + "id": 487, + "field_id": 9071, + "order": "ASC" + } + ], + "decorations": [ + { + "id": 1102, + "type": "left_border_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "88da000f-75a0-4cea-b610-91c08ad179f5", + "color": "darker-red", + "filters": [ + { + "id": "41ec2535-5e8f-4c5c-af82-38e1b0ba713e", + "type": "contains", + "field": 9104, + "group": null, + "value": "Low" + } + ], + "operator": "AND" + }, + { + "id": "4ecf56da-9a3f-439a-b691-dea6a83d4bcf", + "color": "darker-brown", + "filters": [ + { + "id": "5e578f5f-45bd-4fa7-bc98-8fb1c3ae4d91", + "type": "contains", + "field": 9104, + "group": null, + "value": "Medium" + } + ], + "operator": "AND" + }, + { + "id": "e40d0bbd-0761-4f1f-9d39-83315e16675a", + "color": "darker-yellow", + "filters": [ + { + "id": "160140f8-ea91-42db-b736-7bd814c2227c", + "type": "contains", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + }, + { + "id": "8282b080-2f6f-4019-954f-cdd989e4ff66", + "color": "darker-green", + "filters": [ + { + "id": "3f084fed-fdab-4c2f-8dae-c9e03da5ac3c", + "type": "contains", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "row_identifier_type": "id", + "row_height_size": "small", + "field_options": [ + { + "id": 37030, + "field_id": 9064, + "width": 170, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37031, + "field_id": 9065, + "width": 204, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37032, + "field_id": 9066, + "width": 138, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37033, + "field_id": 9067, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37034, + "field_id": 9093, + "width": 281, + "hidden": true, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37035, + "field_id": 9102, + "width": 200, + "hidden": true, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37036, + "field_id": 9100, + "width": 200, + "hidden": true, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37037, + "field_id": 9101, + "width": 200, + "hidden": true, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37038, + "field_id": 9094, + "width": 475, + "hidden": true, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37039, + "field_id": 9095, + "width": 200, + "hidden": true, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37040, + "field_id": 9096, + "width": 200, + "hidden": true, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37041, + "field_id": 9068, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37042, + "field_id": 9069, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37043, + "field_id": 9070, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37044, + "field_id": 9071, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37045, + "field_id": 9072, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37046, + "field_id": 9073, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37047, + "field_id": 9074, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37048, + "field_id": 9075, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37049, + "field_id": 9076, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37050, + "field_id": 9077, + "width": 248, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37051, + "field_id": 9078, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37052, + "field_id": 9079, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37053, + "field_id": 9080, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37054, + "field_id": 9097, + "width": 147, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37055, + "field_id": 9098, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37056, + "field_id": 9103, + "width": 170, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 37057, + "field_id": 9104, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + }, + { + "id": 4437, + "type": "kanban", + "name": "All variants stacked by status", + "order": 11, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "decorations": [], + "public": false, + "single_select_field_id": 9066, + "field_options": [ + { + "id": 5980, + "field_id": 9064, + "hidden": false, + "order": 32767 + }, + { + "id": 5981, + "field_id": 9065, + "hidden": false, + "order": 32767 + }, + { + "id": 5982, + "field_id": 9066, + "hidden": true, + "order": 32767 + }, + { + "id": 5983, + "field_id": 9067, + "hidden": false, + "order": 32767 + }, + { + "id": 5991, + "field_id": 9068, + "hidden": false, + "order": 32767 + }, + { + "id": 5992, + "field_id": 9069, + "hidden": false, + "order": 32767 + }, + { + "id": 5993, + "field_id": 9070, + "hidden": false, + "order": 32767 + }, + { + "id": 5994, + "field_id": 9071, + "hidden": false, + "order": 32767 + }, + { + "id": 5995, + "field_id": 9072, + "hidden": true, + "order": 32767 + }, + { + "id": 5996, + "field_id": 9073, + "hidden": true, + "order": 32767 + }, + { + "id": 5997, + "field_id": 9074, + "hidden": true, + "order": 32767 + }, + { + "id": 5998, + "field_id": 9075, + "hidden": true, + "order": 32767 + }, + { + "id": 5999, + "field_id": 9076, + "hidden": true, + "order": 32767 + }, + { + "id": 6000, + "field_id": 9077, + "hidden": true, + "order": 32767 + }, + { + "id": 6001, + "field_id": 9078, + "hidden": true, + "order": 32767 + }, + { + "id": 6002, + "field_id": 9079, + "hidden": true, + "order": 32767 + }, + { + "id": 6003, + "field_id": 9080, + "hidden": true, + "order": 32767 + }, + { + "id": 5984, + "field_id": 9093, + "hidden": false, + "order": 32767 + }, + { + "id": 5988, + "field_id": 9094, + "hidden": false, + "order": 32767 + }, + { + "id": 5989, + "field_id": 9095, + "hidden": false, + "order": 32767 + }, + { + "id": 5990, + "field_id": 9096, + "hidden": false, + "order": 32767 + }, + { + "id": 6004, + "field_id": 9097, + "hidden": true, + "order": 32767 + }, + { + "id": 6005, + "field_id": 9098, + "hidden": true, + "order": 32767 + }, + { + "id": 5985, + "field_id": 9100, + "hidden": false, + "order": 32767 + }, + { + "id": 5986, + "field_id": 9101, + "hidden": false, + "order": 32767 + }, + { + "id": 5987, + "field_id": 9102, + "hidden": false, + "order": 32767 + }, + { + "id": 6006, + "field_id": 9103, + "hidden": true, + "order": 32767 + }, + { + "id": 6007, + "field_id": 9104, + "hidden": true, + "order": 32767 + } + ] + }, + { + "id": 4438, + "type": "form", + "name": "Add new variant", + "order": 12, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "public": true, + "title": "Add a new experiment variant", + "description": "Fill out all the required information to submit a new variant for experimentation. Ensure each field is thoughtfully completed to maximize success in test management.", + "cover_image": null, + "logo_image": null, + "submit_text": "Submit", + "submit_action": "MESSAGE", + "submit_action_message": "", + "submit_action_redirect_url": "", + "field_options": [ + { + "id": 3291, + "field_id": 9064, + "name": "ID", + "description": "Unique identifier for the variant (typically auto-generated or filled by team lead).", + "enabled": true, + "required": false, + "order": 1, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3292, + "field_id": 9065, + "name": "Name", + "description": "Variant name (concise and descriptive). Example: 'Homepage Red Button'.", + "enabled": true, + "required": true, + "order": 2, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3293, + "field_id": 9066, + "name": "Build status", + "description": "Choose the current stage of this variant in the experimentation cycle.", + "enabled": true, + "required": true, + "order": 3, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3294, + "field_id": 9067, + "name": "Experiment", + "description": "Link to the parent experiment this variant belongs to.", + "enabled": true, + "required": true, + "order": 4, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3295, + "field_id": 9068, + "name": "Test focus", + "description": "Describe what this variant aims to test. Example: 'Impact of button color on conversion rate'.", + "enabled": true, + "required": true, + "order": 5, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3296, + "field_id": 9069, + "name": "Test change", + "description": "Summarize the primary change introduced in this variant. Example: 'Changed CTA text from Buy Now to Shop Today'.", + "enabled": true, + "required": true, + "order": 6, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3297, + "field_id": 9070, + "name": "Design requirements", + "description": "Detail any design specs, resources, or notes needed to build this variant (wireframes, copy, brand guidelines, etc).", + "enabled": true, + "required": false, + "order": 7, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3298, + "field_id": 9071, + "name": "Device", + "description": "Select the primary device this variant targets.", + "enabled": true, + "required": true, + "order": 8, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3299, + "field_id": 9072, + "name": "Potential (1-6)", + "description": "Estimate the potential impact of this variant (1=lowest, 6=highest). Consider expected business/user value.", + "enabled": false, + "required": true, + "order": 9, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3300, + "field_id": 9073, + "name": "Above the fold (0-2)", + "description": "How visible is the change? 0: Not visible without scrolling, 2: Immediately visible upon page load.", + "enabled": false, + "required": true, + "order": 10, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3301, + "field_id": 9074, + "name": "Complexity (1-6)", + "description": "Estimate build/test difficulty (1=simple, 6=complex).", + "enabled": false, + "required": true, + "order": 11, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3302, + "field_id": 9075, + "name": "Based on previous test (0-2)", + "description": "Does this idea build on previous learnings? 0: No, 2: Directly based on tested insight.", + "enabled": false, + "required": true, + "order": 12, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3303, + "field_id": 9076, + "name": "Based on quantitative data (0-2)", + "description": "Strength of data support. 0: No data, 2: Strong data-supported hypothesis.", + "enabled": false, + "required": true, + "order": 13, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3304, + "field_id": 9077, + "name": "Based on qualitative data (0-2)", + "description": "Insightfulness of qualitative research behind this idea. 0: No research, 2: Strong user research.", + "enabled": false, + "required": true, + "order": 14, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3305, + "field_id": 9078, + "name": "Comments", + "description": "Additional notes that do not fit in other fields.", + "enabled": false, + "required": false, + "order": 15, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3306, + "field_id": 9079, + "name": "Images", + "description": "Upload mockups, wireframes, or visual resources for this variant.", + "enabled": true, + "required": false, + "order": 16, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3307, + "field_id": 9080, + "name": "Document", + "description": "Attach supporting documents (design briefs, measurement plans, etc.)", + "enabled": true, + "required": false, + "order": 17, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3308, + "field_id": 9093, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3312, + "field_id": 9094, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3313, + "field_id": 9095, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3314, + "field_id": 9096, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3315, + "field_id": 9097, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3316, + "field_id": 9098, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3309, + "field_id": 9100, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3310, + "field_id": 9101, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3311, + "field_id": 9102, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3317, + "field_id": 9103, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3318, + "field_id": 9104, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + } + ] + }, + { + "id": 4439, + "type": "form", + "name": "Add new variant (all fields)", + "order": 13, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "public": false, + "title": "Add a new experiment variant", + "description": "Fill out all the required information to submit a new variant for experimentation. Ensure each field is thoughtfully completed to maximize success in test management.", + "cover_image": null, + "logo_image": null, + "submit_text": "Submit", + "submit_action": "MESSAGE", + "submit_action_message": "", + "submit_action_redirect_url": "", + "field_options": [ + { + "id": 3319, + "field_id": 9064, + "name": "ID", + "description": "Unique identifier for the variant (typically auto-generated or filled by team lead).", + "enabled": true, + "required": false, + "order": 1, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3320, + "field_id": 9065, + "name": "Name", + "description": "Variant name (concise and descriptive). Example: 'Homepage Red Button'.", + "enabled": true, + "required": true, + "order": 2, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3321, + "field_id": 9066, + "name": "Build status", + "description": "Choose the current stage of this variant in the experimentation cycle.", + "enabled": true, + "required": true, + "order": 3, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3322, + "field_id": 9067, + "name": "Experiment", + "description": "Link to the parent experiment this variant belongs to.", + "enabled": true, + "required": true, + "order": 4, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3323, + "field_id": 9068, + "name": "Test focus", + "description": "Describe what this variant aims to test. Example: 'Impact of button color on conversion rate'.", + "enabled": true, + "required": true, + "order": 5, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3324, + "field_id": 9069, + "name": "Test change", + "description": "Summarize the primary change introduced in this variant. Example: 'Changed CTA text from Buy Now to Shop Today'.", + "enabled": true, + "required": true, + "order": 6, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3325, + "field_id": 9070, + "name": "Design requirements", + "description": "Detail any design specs, resources, or notes needed to build this variant (wireframes, copy, brand guidelines, etc).", + "enabled": true, + "required": false, + "order": 7, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3326, + "field_id": 9071, + "name": "Device", + "description": "Select the primary device this variant targets.", + "enabled": true, + "required": true, + "order": 8, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3327, + "field_id": 9072, + "name": "Potential (1-6)", + "description": "Estimate the potential impact of this variant (1=lowest, 6=highest). Consider expected business/user value.", + "enabled": true, + "required": true, + "order": 9, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3328, + "field_id": 9073, + "name": "Above the fold (0-2)", + "description": "How visible is the change? 0: Not visible without scrolling, 2: Immediately visible upon page load.", + "enabled": true, + "required": true, + "order": 10, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3329, + "field_id": 9074, + "name": "Complexity (1-6)", + "description": "Estimate build/test difficulty (1=simple, 6=complex).", + "enabled": true, + "required": true, + "order": 11, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3330, + "field_id": 9075, + "name": "Based on previous test (0-2)", + "description": "Does this idea build on previous learnings? 0: No, 2: Directly based on tested insight.", + "enabled": true, + "required": true, + "order": 12, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3331, + "field_id": 9076, + "name": "Based on quantitative data (0-2)", + "description": "Strength of data support. 0: No data, 2: Strong data-supported hypothesis.", + "enabled": true, + "required": true, + "order": 13, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3332, + "field_id": 9077, + "name": "Based on qualitative data (0-2)", + "description": "Insightfulness of qualitative research behind this idea. 0: No research, 2: Strong user research.", + "enabled": true, + "required": true, + "order": 14, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3333, + "field_id": 9078, + "name": "Comments", + "description": "Additional notes that do not fit in other fields.", + "enabled": true, + "required": false, + "order": 15, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3334, + "field_id": 9079, + "name": "Images", + "description": "Upload mockups, wireframes, or visual resources for this variant.", + "enabled": true, + "required": false, + "order": 16, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3335, + "field_id": 9080, + "name": "Document", + "description": "Attach supporting documents (design briefs, measurement plans, etc.)", + "enabled": true, + "required": false, + "order": 17, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3336, + "field_id": 9093, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3340, + "field_id": 9094, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3341, + "field_id": 9095, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3342, + "field_id": 9096, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3343, + "field_id": 9097, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3344, + "field_id": 9098, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3337, + "field_id": 9100, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3338, + "field_id": 9101, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3339, + "field_id": 9102, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3345, + "field_id": 9103, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + }, + { + "id": 3346, + "field_id": 9104, + "name": "", + "description": "", + "enabled": false, + "required": true, + "order": 32767, + "show_when_matching_conditions": false, + "condition_type": "AND", + "conditions": [], + "condition_groups": [], + "field_component": "default", + "include_all_select_options": true, + "allowed_select_options": [] + } + ] + }, + { + "id": 4440, + "type": "gallery", + "name": "Gallery: all variants", + "order": 14, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "decorations": [], + "public": false, + "card_cover_image_field_id": 9079, + "field_options": [ + { + "id": 3147, + "field_id": 9064, + "hidden": false, + "order": 32767 + }, + { + "id": 3148, + "field_id": 9065, + "hidden": false, + "order": 32767 + }, + { + "id": 3149, + "field_id": 9066, + "hidden": false, + "order": 32767 + }, + { + "id": 3150, + "field_id": 9067, + "hidden": true, + "order": 32767 + }, + { + "id": 3158, + "field_id": 9068, + "hidden": true, + "order": 32767 + }, + { + "id": 3159, + "field_id": 9069, + "hidden": true, + "order": 32767 + }, + { + "id": 3160, + "field_id": 9070, + "hidden": true, + "order": 32767 + }, + { + "id": 3161, + "field_id": 9071, + "hidden": true, + "order": 32767 + }, + { + "id": 3162, + "field_id": 9072, + "hidden": true, + "order": 32767 + }, + { + "id": 3163, + "field_id": 9073, + "hidden": true, + "order": 32767 + }, + { + "id": 3164, + "field_id": 9074, + "hidden": true, + "order": 32767 + }, + { + "id": 3165, + "field_id": 9075, + "hidden": true, + "order": 32767 + }, + { + "id": 3166, + "field_id": 9076, + "hidden": true, + "order": 32767 + }, + { + "id": 3167, + "field_id": 9077, + "hidden": true, + "order": 32767 + }, + { + "id": 3168, + "field_id": 9078, + "hidden": true, + "order": 32767 + }, + { + "id": 3169, + "field_id": 9079, + "hidden": true, + "order": 32767 + }, + { + "id": 3170, + "field_id": 9080, + "hidden": true, + "order": 32767 + }, + { + "id": 3151, + "field_id": 9093, + "hidden": true, + "order": 32767 + }, + { + "id": 3155, + "field_id": 9094, + "hidden": true, + "order": 32767 + }, + { + "id": 3156, + "field_id": 9095, + "hidden": true, + "order": 32767 + }, + { + "id": 3157, + "field_id": 9096, + "hidden": true, + "order": 32767 + }, + { + "id": 3171, + "field_id": 9097, + "hidden": true, + "order": 32767 + }, + { + "id": 3172, + "field_id": 9098, + "hidden": true, + "order": 32767 + }, + { + "id": 3152, + "field_id": 9100, + "hidden": true, + "order": 32767 + }, + { + "id": 3153, + "field_id": 9101, + "hidden": true, + "order": 32767 + }, + { + "id": 3154, + "field_id": 9102, + "hidden": true, + "order": 32767 + }, + { + "id": 3173, + "field_id": 9103, + "hidden": true, + "order": 32767 + }, + { + "id": 3174, + "field_id": 9104, + "hidden": true, + "order": 32767 + } + ] + }, + { + "id": 4441, + "type": "gallery", + "name": "Gallery: high + top priority variants", + "order": 15, + "ownership_type": "collaborative", + "owned_by": "frederik@baserow.io", + "filter_type": "OR", + "filters_disabled": false, + "filters": [ + { + "id": 2659, + "field_id": 9104, + "type": "contains_word", + "value": "high", + "group": null + }, + { + "id": 2660, + "field_id": 9104, + "type": "contains_word", + "value": "top", + "group": null + } + ], + "filter_groups": [], + "sortings": [ + { + "id": 2892, + "field_id": 9104, + "order": "DESC" + } + ], + "decorations": [ + { + "id": 1103, + "type": "background_color", + "value_provider_type": "conditional_color", + "value_provider_conf": { + "colors": [ + { + "id": "629ba24d-a7a2-4a00-9aa5-591758dc03e1", + "color": "light-green", + "filters": [ + { + "id": "187c8485-09a1-4286-873c-45a3d54eb99d", + "type": "contains_word", + "field": 9104, + "group": null, + "value": "Top" + } + ], + "operator": "AND" + }, + { + "id": "dbfa677b-b443-46aa-abb8-a73c58f9e9a8", + "color": "light-yellow", + "filters": [ + { + "id": "44bceee6-dc61-46f0-adf1-dc66b8120458", + "type": "contains_word", + "field": 9104, + "group": null, + "value": "High" + } + ], + "operator": "AND" + } + ] + }, + "order": 1 + } + ], + "public": false, + "card_cover_image_field_id": 9079, + "field_options": [ + { + "id": 3175, + "field_id": 9064, + "hidden": false, + "order": 32767 + }, + { + "id": 3176, + "field_id": 9065, + "hidden": false, + "order": 32767 + }, + { + "id": 3177, + "field_id": 9066, + "hidden": false, + "order": 32767 + }, + { + "id": 3178, + "field_id": 9067, + "hidden": true, + "order": 32767 + }, + { + "id": 3186, + "field_id": 9068, + "hidden": true, + "order": 32767 + }, + { + "id": 3187, + "field_id": 9069, + "hidden": true, + "order": 32767 + }, + { + "id": 3188, + "field_id": 9070, + "hidden": true, + "order": 32767 + }, + { + "id": 3189, + "field_id": 9071, + "hidden": true, + "order": 32767 + }, + { + "id": 3190, + "field_id": 9072, + "hidden": true, + "order": 32767 + }, + { + "id": 3191, + "field_id": 9073, + "hidden": true, + "order": 32767 + }, + { + "id": 3192, + "field_id": 9074, + "hidden": true, + "order": 32767 + }, + { + "id": 3193, + "field_id": 9075, + "hidden": true, + "order": 32767 + }, + { + "id": 3194, + "field_id": 9076, + "hidden": true, + "order": 32767 + }, + { + "id": 3195, + "field_id": 9077, + "hidden": true, + "order": 32767 + }, + { + "id": 3196, + "field_id": 9078, + "hidden": true, + "order": 32767 + }, + { + "id": 3197, + "field_id": 9079, + "hidden": true, + "order": 32767 + }, + { + "id": 3198, + "field_id": 9080, + "hidden": true, + "order": 32767 + }, + { + "id": 3179, + "field_id": 9093, + "hidden": true, + "order": 32767 + }, + { + "id": 3183, + "field_id": 9094, + "hidden": true, + "order": 32767 + }, + { + "id": 3184, + "field_id": 9095, + "hidden": true, + "order": 32767 + }, + { + "id": 3185, + "field_id": 9096, + "hidden": true, + "order": 32767 + }, + { + "id": 3199, + "field_id": 9097, + "hidden": false, + "order": 32767 + }, + { + "id": 3200, + "field_id": 9098, + "hidden": false, + "order": 32767 + }, + { + "id": 3180, + "field_id": 9100, + "hidden": true, + "order": 32767 + }, + { + "id": 3181, + "field_id": 9101, + "hidden": true, + "order": 32767 + }, + { + "id": 3182, + "field_id": 9102, + "hidden": true, + "order": 32767 + }, + { + "id": 3201, + "field_id": 9103, + "hidden": false, + "order": 32767 + }, + { + "id": 3202, + "field_id": 9104, + "hidden": true, + "order": 32767 + } + ] + } + ], + "rows": [ + { + "id": 4, + "order": "4.00000000000000000000", + "created_on": "2025-11-13T09:48:28.634139+00:00", + "updated_on": "2026-01-12T13:46:53.236304+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "INST-001-B", + "field_9065": "Lisa Finishes Work Early", + "field_9066": 3507, + "field_9067": [ + 3 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Ad copy test", + "field_9069": "Switch CTA position", + "field_9070": "Use brand font", + "field_9071": 3511, + "field_9072": 3, + "field_9073": 3516, + "field_9074": 3, + "field_9075": 3518, + "field_9076": 3523, + "field_9077": 3526, + "field_9078": "Focus on typography", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 5, + "order": "5.00000000000000000000", + "created_on": "2025-11-13T09:48:28.634737+00:00", + "updated_on": "2026-01-12T13:46:55.122274+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "INST-001-C", + "field_9065": "Alicia's Quick Break", + "field_9066": 3508, + "field_9067": [ + 3 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Length of video", + "field_9069": "Reduce video length", + "field_9070": "Keep below 8 sec", + "field_9071": 3512, + "field_9072": 1, + "field_9073": 3517, + "field_9074": 2, + "field_9075": 3520, + "field_9076": 3522, + "field_9077": 3525, + "field_9078": "Testing short story engagement", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 6, + "order": "6.00000000000000000000", + "created_on": "2025-11-13T09:48:28.635338+00:00", + "updated_on": "2026-01-12T13:46:55.524858+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "INST-001-D", + "field_9065": "Morning Espresso Pull", + "field_9066": 3509, + "field_9067": [ + 3 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Swipe gesture", + "field_9069": "Enable swipe up", + "field_9070": "Interactive gesture required", + "field_9071": 3513, + "field_9072": 6, + "field_9073": 3517, + "field_9074": 1, + "field_9075": 3520, + "field_9076": 3523, + "field_9077": 3526, + "field_9078": "Gesture control assessment", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 3, + "order": "6.50000000000000000000", + "created_on": "2025-11-13T09:48:28.629709+00:00", + "updated_on": "2026-01-12T13:46:55.834861+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "INST-001-A", + "field_9065": "Bruno's Morning Coffee Story", + "field_9066": 3510, + "field_9067": [ + 3 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Focus on video engagement", + "field_9069": "Change background color", + "field_9070": "Must be mobile friendly", + "field_9071": 3512, + "field_9072": 5, + "field_9073": 3517, + "field_9074": 2, + "field_9075": 3519, + "field_9076": 3523, + "field_9077": 3525, + "field_9078": "Initial concept, testing colors", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 7, + "order": "7.00000000000000000000", + "created_on": "2025-11-13T09:48:28.635762+00:00", + "updated_on": "2026-01-12T13:46:56.142619+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "INST-001-E", + "field_9065": "Jane's Coffee and Go", + "field_9066": 3510, + "field_9067": [ + 3 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Hashtag inclusion", + "field_9069": "Add Gen Z hashtag", + "field_9070": "Trending tags only", + "field_9071": 3512, + "field_9072": 5, + "field_9073": 3517, + "field_9074": 2, + "field_9075": 3520, + "field_9076": 3523, + "field_9077": 3526, + "field_9078": "Live variant, tagging impact", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 8, + "order": "8.00000000000000000000", + "created_on": "2025-11-13T09:48:37.915106+00:00", + "updated_on": "2026-01-12T13:46:56.426407+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "WAF-PRIZE-A", + "field_9065": "Win Cinema Tickets", + "field_9066": 3506, + "field_9067": [ + 4 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Participation boost", + "field_9069": "Add leaderboard", + "field_9070": "Real-time update", + "field_9071": 3512, + "field_9072": 4, + "field_9073": 3517, + "field_9074": 3, + "field_9075": 3520, + "field_9076": 3522, + "field_9077": 3525, + "field_9078": "Gamification approach", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 9, + "order": "9.00000000000000000000", + "created_on": "2025-11-13T09:48:37.915334+00:00", + "updated_on": "2026-01-12T13:46:56.717389+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "WAF-PRIZE-B", + "field_9065": "Chocolate Weekend Box", + "field_9066": 3507, + "field_9067": [ + 4 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Hashtag use", + "field_9069": "Feature trending hashtag", + "field_9070": "Dynamic update", + "field_9071": 3511, + "field_9072": 2, + "field_9073": 3515, + "field_9074": 4, + "field_9075": 3518, + "field_9076": 3523, + "field_9077": 3526, + "field_9078": "Topical tags for reach", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 10, + "order": "10.00000000000000000000", + "created_on": "2025-11-13T09:48:37.915474+00:00", + "updated_on": "2026-01-12T13:46:57.099853+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "WAF-PRIZE-C", + "field_9065": "Cashback for Top Posts", + "field_9066": 3508, + "field_9067": [ + 4 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Reward size", + "field_9069": "Increase prizes", + "field_9070": "Highlight rewards", + "field_9071": 3513, + "field_9072": 3, + "field_9073": 3516, + "field_9074": 3, + "field_9075": 3519, + "field_9076": 3523, + "field_9077": 3525, + "field_9078": "Testing incentive uplift", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 11, + "order": "11.00000000000000000000", + "created_on": "2025-11-13T09:48:37.915599+00:00", + "updated_on": "2026-01-12T13:46:57.419337+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "WAF-PRIZE-D", + "field_9065": "Merchandise Bundle", + "field_9066": 3509, + "field_9067": [ + 4 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "User reminders", + "field_9069": "Send push notifications", + "field_9070": "Opt-in notification", + "field_9071": 3512, + "field_9072": 4, + "field_9073": 3516, + "field_9074": 1, + "field_9075": 3520, + "field_9076": 3522, + "field_9077": 3526, + "field_9078": "Testing engagement nudge", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 12, + "order": "12.00000000000000000000", + "created_on": "2025-11-13T09:48:37.915725+00:00", + "updated_on": "2026-01-12T13:46:57.759080+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "WAF-PRIZE-E", + "field_9065": "Meet the Mascot Event", + "field_9066": 3510, + "field_9067": [ + 4 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Visual callout", + "field_9069": "Highlight prize pool", + "field_9070": "Bold visuals", + "field_9071": 3514, + "field_9072": 4, + "field_9073": 3517, + "field_9074": 2, + "field_9075": 3519, + "field_9076": 3522, + "field_9077": 3526, + "field_9078": "Live variant qualities", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 13, + "order": "13.00000000000000000000", + "created_on": "2025-11-13T09:48:48.412125+00:00", + "updated_on": "2026-01-12T13:46:58.046559+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "SAV-RECIPE-1", + "field_9065": "One-Pot Veggie Dinner", + "field_9066": 3506, + "field_9067": [ + 5 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Image vs. Video", + "field_9069": "Replace image with video", + "field_9070": "Quick recipe section", + "field_9071": 3511, + "field_9072": 3, + "field_9073": 3516, + "field_9074": 4, + "field_9075": 3518, + "field_9076": 3523, + "field_9077": 3525, + "field_9078": "Testing static to video", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 14, + "order": "14.00000000000000000000", + "created_on": "2025-11-13T09:48:48.412367+00:00", + "updated_on": "2026-01-12T13:46:58.366255+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "SAV-RECIPE-2", + "field_9065": "Changing the title", + "field_9066": 3507, + "field_9067": [ + 5 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Video placement", + "field_9069": "Move video above text", + "field_9070": "Highlight visuals", + "field_9071": 3512, + "field_9072": 2, + "field_9073": 3517, + "field_9074": 1, + "field_9075": 3520, + "field_9076": 3522, + "field_9077": 3526, + "field_9078": "Above the fold effect", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 15, + "order": "15.00000000000000000000", + "created_on": "2025-11-13T09:48:48.412467+00:00", + "updated_on": "2026-01-12T13:46:58.735448+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "SAV-RECIPE-3", + "field_9065": "Mushroom Risotto Express", + "field_9066": 3508, + "field_9067": [ + 5 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Call-to-action", + "field_9069": "Change CTA color", + "field_9070": "Red accent", + "field_9071": 3513, + "field_9072": 5, + "field_9073": 3517, + "field_9074": 2, + "field_9075": 3520, + "field_9076": 3522, + "field_9077": 3526, + "field_9078": "CTA prominence", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 16, + "order": "16.00000000000000000000", + "created_on": "2025-11-13T09:48:48.412560+00:00", + "updated_on": "2026-01-12T13:46:59.049083+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "SAV-RECIPE-4", + "field_9065": "Sunday Breakfast Pancakes", + "field_9066": 3509, + "field_9067": [ + 5 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Step-by-step layout", + "field_9069": "Add numbered steps", + "field_9070": "Step indicator", + "field_9071": 3511, + "field_9072": 4, + "field_9073": 3515, + "field_9074": 3, + "field_9075": 3518, + "field_9076": 3523, + "field_9077": 3526, + "field_9078": "Step clarity test", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 17, + "order": "17.00000000000000000000", + "created_on": "2025-11-13T09:48:48.412652+00:00", + "updated_on": "2026-01-12T13:46:59.353132+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "SAV-RECIPE-5", + "field_9065": "Garden Salad w/ Herbs", + "field_9066": 3510, + "field_9067": [ + 5 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Shareable option", + "field_9069": "Enable social share", + "field_9070": "Social icons visible", + "field_9071": 3512, + "field_9072": 3, + "field_9073": 3516, + "field_9074": 4, + "field_9075": 3520, + "field_9076": 3523, + "field_9077": 3525, + "field_9078": "Live variant, social sharing", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 18, + "order": "18.00000000000000000000", + "created_on": "2025-11-13T09:48:57.113323+00:00", + "updated_on": "2026-01-12T13:46:59.718745+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "PURELIFE-SAMPLE-A", + "field_9065": "Weekday Busy Path", + "field_9066": 3506, + "field_9067": [ + 6 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Sampling location", + "field_9069": "Change park area", + "field_9070": "High traffic", + "field_9071": 3513, + "field_9072": 1, + "field_9073": 3517, + "field_9074": 3, + "field_9075": 3519, + "field_9076": 3523, + "field_9077": 3525, + "field_9078": "Evaluate location effectiveness", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 19, + "order": "19.00000000000000000000", + "created_on": "2025-11-13T09:48:57.113497+00:00", + "updated_on": "2026-01-12T13:47:00.072998+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "PURELIFE-SAMPLE-B", + "field_9065": "Family Picnic Corner", + "field_9066": 3507, + "field_9067": [ + 6 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Incentive type", + "field_9069": "Add coupon", + "field_9070": "Easy to redeem", + "field_9071": 3512, + "field_9072": 4, + "field_9073": 3515, + "field_9074": 2, + "field_9075": 3518, + "field_9076": 3522, + "field_9077": 3526, + "field_9078": "Coupon impact trial", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 20, + "order": "20.00000000000000000000", + "created_on": "2025-11-13T09:48:57.113614+00:00", + "updated_on": "2026-01-12T13:47:00.882174+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "PURELIFE-SAMPLE-C", + "field_9065": "Dog Walkers Gathering", + "field_9066": 3508, + "field_9067": [ + 6 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Branding", + "field_9069": "Update bottle design", + "field_9070": "Eco-friendly label", + "field_9071": 3511, + "field_9072": 6, + "field_9073": 3516, + "field_9074": 1, + "field_9075": 3520, + "field_9076": 3523, + "field_9077": 3526, + "field_9078": "Bottle redesign test", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 21, + "order": "21.00000000000000000000", + "created_on": "2025-11-13T09:48:57.113728+00:00", + "updated_on": "2026-01-12T13:47:01.173246+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "PURELIFE-SAMPLE-D", + "field_9065": "Sunset Yoga Spot", + "field_9066": 3509, + "field_9067": [ + 6 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Sampling time", + "field_9069": "Evening slot", + "field_9070": "Lighting optimized", + "field_9071": 3512, + "field_9072": 2, + "field_9073": 3517, + "field_9074": 3, + "field_9075": 3519, + "field_9076": 3523, + "field_9077": 3524, + "field_9078": "Testing sampling time effect", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 22, + "order": "22.00000000000000000000", + "created_on": "2025-11-13T09:48:57.113834+00:00", + "updated_on": "2026-01-12T13:47:02.814241+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "PURELIFE-SAMPLE-E", + "field_9065": "Health Expo Booth", + "field_9066": 3510, + "field_9067": [ + 6 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Promotion visibility", + "field_9069": "Large signage", + "field_9070": "High contrast signs", + "field_9071": 3514, + "field_9072": 6, + "field_9073": 3516, + "field_9074": 2, + "field_9075": 3520, + "field_9076": 3522, + "field_9077": 3525, + "field_9078": "Live - measuring promotion recall", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 23, + "order": "23.00000000000000000000", + "created_on": "2025-11-13T09:49:09.355797+00:00", + "updated_on": "2026-01-12T13:47:02.427700+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "POD-TASTE-A", + "field_9065": "Luca's First Espresso Shot", + "field_9066": 3506, + "field_9067": [ + 7 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Signup prompt", + "field_9069": "Popup on landing", + "field_9070": "Minimalist design", + "field_9071": 3511, + "field_9072": 3, + "field_9073": 3517, + "field_9074": 5, + "field_9075": 3519, + "field_9076": 3521, + "field_9077": 3526, + "field_9078": "First approach on-site", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 24, + "order": "24.00000000000000000000", + "created_on": "2025-11-13T09:49:09.356012+00:00", + "updated_on": "2026-01-12T13:47:03.360422+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "POD-TASTE-B", + "field_9065": "Afternoon Treats Signup", + "field_9066": 3507, + "field_9067": [ + 7 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Offer wording", + "field_9069": "Alter signup description", + "field_9070": "Friendly copy", + "field_9071": 3512, + "field_9072": 4, + "field_9073": 3516, + "field_9074": 4, + "field_9075": 3518, + "field_9076": 3523, + "field_9077": 3525, + "field_9078": "A/B copywriting", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 25, + "order": "25.00000000000000000000", + "created_on": "2025-11-13T09:49:09.356187+00:00", + "updated_on": "2026-01-12T13:47:03.804113+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "POD-TASTE-C", + "field_9065": "Sampling with Emma", + "field_9066": 3508, + "field_9067": [ + 7 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Form fields", + "field_9069": "Reduce required info", + "field_9070": "Easy subscribe", + "field_9071": 3513, + "field_9072": 3, + "field_9073": 3515, + "field_9074": 2, + "field_9075": 3520, + "field_9076": 3522, + "field_9077": 3525, + "field_9078": "Lower friction", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 26, + "order": "26.00000000000000000000", + "created_on": "2025-11-13T09:49:09.356417+00:00", + "updated_on": "2026-01-12T13:47:04.138898+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "POD-TASTE-D", + "field_9065": "Saturday Latte Rush", + "field_9066": 3509, + "field_9067": [ + 7 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "CTA color", + "field_9069": "Use green button", + "field_9070": "Button visibility", + "field_9071": 3512, + "field_9072": 4, + "field_9073": 3517, + "field_9074": 3, + "field_9075": 3519, + "field_9076": 3523, + "field_9077": 3524, + "field_9078": "Final visual variant", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + }, + { + "id": 27, + "order": "27.00000000000000000000", + "created_on": "2025-11-13T09:49:09.356581+00:00", + "updated_on": "2026-01-12T13:47:04.445002+00:00", + "created_by": "frederik@baserow.io", + "last_modified_by": "frederik@baserow.io", + "field_9064": "POD-TASTE-E", + "field_9065": "Anna's Thank You Page", + "field_9066": 3510, + "field_9067": [ + 7 + ], + "field_9093": null, + "field_9100": null, + "field_9101": null, + "field_9102": null, + "field_9094": null, + "field_9095": null, + "field_9096": null, + "field_9068": "Confirmation", + "field_9069": "Show thank you screen", + "field_9070": "On-brand, quick feedback", + "field_9071": 3512, + "field_9072": 3, + "field_9073": 3516, + "field_9074": 1, + "field_9075": 3520, + "field_9076": 3523, + "field_9077": 3526, + "field_9078": "Tracking conversion impact", + "field_9079": [ + { + "name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "visible_name": "default-menu-image-placeholder.png", + "original_name": "kLmQ5KJWfparh8KbSZP2QAiB87B0N45Y_b205508285d9eb121496336022cba718c5b4cf7af39c838a80466ead70beea4d.png", + "size": 17269 + } + ], + "field_9080": [ + { + "name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "visible_name": "invoice-demo.pdf", + "original_name": "eftxFRWti1XfuP5Qw0i43GxmtKaZ6MED_c2de358a0974f9b047aed8e457def7d4b1b39170f44819ec3152b42db787d72e.pdf", + "size": 43627 + } + ], + "field_9097": null, + "field_9098": null, + "field_9103": null, + "field_9104": null + } + ], + "data_sync": null, + "field_rules": [] + } + ] + }, + { + "pages": [ + { + "id": 552, + "name": "__shared__", + "order": 1, + "path": "__shared__", + "path_params": [], + "query_params": [], + "shared": true, + "elements": [ + { + "id": 8021, + "order": "1.00000000000000000000", + "type": "header", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "share_type": "all", + "pages": [] + }, + { + "id": 8022, + "order": "1.00000000000000000000", + "type": "image", + "parent_element_id": 8021, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "image_source_type": "upload", + "image_file_id": { + "name": "pIKNEgRMlzutiqDmQDe9H5u7gAGeyA3K_19f8e94b815ae7415c8032ce03c362000e30184a0bbc4b098debe63087394231.png", + "original_name": "application-header.png" + }, + "image_url": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "alt_text": { + "mode": "simple", + "version": "0.1", + "formula": "" + } + }, + { + "id": 8145, + "order": "2.00000000000000000000", + "type": "menu", + "parent_element_id": 8021, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "orientation": "horizontal", + "alignment": "left", + "menu_items": [ + { + "id": 376, + "variant": "link", + "type": "link", + "menu_item_order": 0, + "uid": "40fe79be-1edd-417f-a48b-3bdab8359027", + "name": "Home", + "navigation_type": "page", + "navigate_to_page_id": 553, + "navigate_to_url": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "page_parameters": [], + "query_parameters": [], + "parent_menu_item": null, + "target": "self", + "children": [] + }, + { + "id": 377, + "variant": "link", + "type": "separator", + "menu_item_order": 1, + "uid": "06202849-18b5-429e-b652-890a3f5c1d11", + "name": "Page", + "navigation_type": "page", + "navigate_to_page_id": null, + "navigate_to_url": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "page_parameters": [], + "query_parameters": [], + "parent_menu_item": null, + "target": "self", + "children": [] + } + ] + } + ], + "data_sources": [], + "workflow_actions": [], + "visibility": "all", + "role_type": "allow_all", + "roles": [] + }, + { + "id": 553, + "name": "Homepage", + "order": 1, + "path": "/", + "path_params": [], + "query_params": [], + "shared": false, + "elements": [ + { + "id": 8023, + "order": "1.00000000000000000000", + "type": "heading", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'Running experiments'" + }, + "level": 1 + }, + { + "id": 8024, + "order": "2.00000000000000000000", + "type": "table", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "data_source_id": 982, + "items_per_page": 20, + "button_load_more_label": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "schema_property": null, + "property_options": [], + "fields": [ + { + "uid": "e74bc8b2-78fd-4c9e-a690-9fbedb876541", + "name": "Name", + "type": "link", + "styles": {}, + "config": { + "navigation_type": "page", + "navigate_to_page_id": 554, + "page_parameters": [ + { + "name": "experiment_id", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.id')" + } + } + ], + "query_parameters": [], + "navigate_to_url": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "target": "self", + "link_name": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9053')" + }, + "variant": "link" + } + }, + { + "uid": "49999ef9-3564-4063-bef4-e2992542987e", + "name": "Start", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9057')" + } + } + }, + { + "uid": "a497d83a-a3ce-42fb-b420-110f90b9dfdc", + "name": "End", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9058')" + } + } + }, + { + "uid": "ca9a1c41-b727-435c-9d57-3fc23fcf1094", + "name": "Owner", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9059.*.value')" + } + } + }, + { + "uid": "ec6ef536-f8a0-4b37-ad0e-9cb28cb6b28e", + "name": "Campaign", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9061.*.value')" + } + } + }, + { + "uid": "9264b03f-d003-4345-880e-0b043ab03e5c", + "name": "Market", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9086')" + } + } + }, + { + "uid": "553aabeb-b77d-4dec-b9cb-6ea8ee199acf", + "name": "Priority", + "type": "tags", + "styles": {}, + "config": { + "values": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9063.value')" + }, + "colors_is_formula": true, + "colors": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9063.color')" + } + } + } + ], + "orientation": { + "tablet": "horizontal", + "desktop": "horizontal", + "smartphone": "horizontal" + } + } + ], + "data_sources": [ + { + "id": 982, + "name": "Running experiments", + "order": "1.00000000000000000000", + "service": { + "id": 1755, + "integration_id": 184, + "type": "local_baserow_list_rows", + "sample_data": null, + "table_id": 968, + "view_id": 4421, + "sortings": [], + "search_query": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "filter_type": "AND", + "filters": [], + "default_result_count": 20 + } + } + ], + "workflow_actions": [], + "visibility": "all", + "role_type": "allow_all", + "roles": [] + }, + { + "id": 554, + "name": "Variant overview", + "order": 2, + "path": "/variant-overview/:experiment_id", + "path_params": [ + { + "name": "experiment_id", + "type": "numeric" + } + ], + "query_params": [], + "shared": false, + "elements": [ + { + "id": 8026, + "order": "1.00000000000000000000", + "type": "heading", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "concat(get('data_source.983.field_9053'),' (',get('data_source.983.field_9089'),' variants)')" + }, + "level": 1 + }, + { + "id": 8027, + "order": "1.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'CAMPAIGN'" + }, + "format": "plain" + }, + { + "id": 8028, + "order": "1.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'HYPOTHESIS'" + }, + "format": "plain" + }, + { + "id": 8025, + "order": "2.00000000000000000000", + "type": "column", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 1, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 1, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "column_amount": 2, + "column_gap": 20, + "alignment": "top" + }, + { + "id": 8029, + "order": "2.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.983.field_9061.0.value')" + }, + "format": "plain" + }, + { + "id": 8030, + "order": "2.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.983.field_9054')" + }, + "format": "plain" + }, + { + "id": 8031, + "order": "3.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'OBJECTIVE'" + }, + "format": "plain" + }, + { + "id": 8032, + "order": "3.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'PRIMARY KPI / SECONDARY KPI'" + }, + "format": "plain" + }, + { + "id": 8033, + "order": "3.00000000000000000000", + "type": "heading", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 30, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'Existing variants (summary)'" + }, + "level": 2 + }, + { + "id": 8034, + "order": "4.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.983.field_9088')" + }, + "format": "plain" + }, + { + "id": 8035, + "order": "4.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "concat(get('data_source.983.field_9055.0.value'),' / ',get('data_source.983.field_9056.0.value'))" + }, + "format": "plain" + }, + { + "id": 8036, + "order": "4.00000000000000000000", + "type": "table", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 0, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "data_source_id": 984, + "items_per_page": 20, + "button_load_more_label": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "schema_property": null, + "property_options": [], + "fields": [ + { + "uid": "566dedbc-7be7-4257-beae-574dbc7aece9", + "name": "ID", + "type": "link", + "styles": {}, + "config": { + "navigation_type": "page", + "navigate_to_page_id": 555, + "page_parameters": [ + { + "name": "experiment_id", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('page_parameter.experiment_id')" + } + }, + { + "name": "variant_id", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.id')" + } + } + ], + "query_parameters": [], + "navigate_to_url": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "target": "self", + "link_name": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9064')" + }, + "variant": "link" + } + }, + { + "uid": "8f1adc06-7301-4442-871d-087475a38d00", + "name": "Name", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9065')" + } + } + }, + { + "uid": "2cf8363d-d1b2-43b2-8fd5-9f0793efda77", + "name": "Status", + "type": "tags", + "styles": {}, + "config": { + "values": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9066.value')" + }, + "colors_is_formula": true, + "colors": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9066.color')" + } + } + }, + { + "uid": "3ee14fd2-17cd-45ca-97a9-900d10ee00c6", + "name": "Test focus", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9068')" + } + } + }, + { + "uid": "9cf33f05-0382-4775-99d5-c6a2669419e6", + "name": "Test change", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9069')" + } + } + }, + { + "uid": "683d45e8-fac4-4f6e-b175-304118dad78b", + "name": "Priority", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9097')" + } + } + }, + { + "uid": "39ac2113-1739-486c-9696-2ef9b12fc8c7", + "name": "Confidence", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9098')" + } + } + }, + { + "uid": "a9b6ee6a-fded-4a47-a6f5-351e27a806d6", + "name": "Opportunity", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9103')" + } + } + }, + { + "uid": "02627e70-0eaa-4241-ba31-6be363578720", + "name": "Conclusion", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9104')" + } + } + } + ], + "orientation": { + "tablet": "horizontal", + "desktop": "horizontal", + "smartphone": "horizontal" + } + }, + { + "id": 8037, + "order": "5.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'MARKET'" + }, + "format": "plain" + }, + { + "id": 8038, + "order": "5.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'START / END'" + }, + "format": "plain" + }, + { + "id": 8039, + "order": "6.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.983.field_9086')" + }, + "format": "plain" + }, + { + "id": 8040, + "order": "6.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "concat(get('data_source.983.field_9057'),' / ',get('data_source.983.field_9058'))" + }, + "format": "plain" + }, + { + "id": 8041, + "order": "7.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'BRAND'" + }, + "format": "plain" + }, + { + "id": 8042, + "order": "7.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'OWNER'" + }, + "format": "plain" + }, + { + "id": 8043, + "order": "8.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.983.field_9087')" + }, + "format": "plain" + }, + { + "id": 8044, + "order": "8.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.983.field_9059.0.value')" + }, + "format": "plain" + }, + { + "id": 8045, + "order": "9.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'PRIORITY'" + }, + "format": "plain" + }, + { + "id": 8046, + "order": "10.00000000000000000000", + "type": "text", + "parent_element_id": 8025, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "concat(get('data_source.983.field_9059.0.value'),get('data_source.983.field_9063.value'))" + }, + "format": "plain" + } + ], + "data_sources": [ + { + "id": 983, + "name": "Get experiment", + "order": "1.00000000000000000000", + "service": { + "id": 1756, + "integration_id": 184, + "type": "local_baserow_get_row", + "sample_data": null, + "table_id": 968, + "view_id": null, + "search_query": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "filter_type": "AND", + "filters": [], + "row_id": { + "mode": "simple", + "version": "0.1", + "formula": "get('page_parameter.experiment_id')" + } + } + }, + { + "id": 984, + "name": "Related variants", + "order": "2.00000000000000000000", + "service": { + "id": 1757, + "integration_id": 184, + "type": "local_baserow_list_rows", + "sample_data": null, + "table_id": 969, + "view_id": 4427, + "sortings": [], + "search_query": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "filter_type": "AND", + "filters": [ + { + "field_id": 9067, + "type": "link_row_has", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('page_parameter.experiment_id')" + }, + "value_is_formula": true + } + ], + "default_result_count": 20 + } + } + ], + "workflow_actions": [], + "visibility": "all", + "role_type": "allow_all", + "roles": [] + }, + { + "id": 555, + "name": "Variant details", + "order": 3, + "path": "/variant-details/:experiment_id/:variant_id", + "path_params": [ + { + "name": "experiment_id", + "type": "numeric" + }, + { + "name": "variant_id", + "type": "numeric" + } + ], + "query_params": [], + "shared": false, + "elements": [ + { + "id": 8056, + "order": "0.50000000000000000000", + "type": "link", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "navigation_type": "page", + "navigate_to_page_id": 554, + "page_parameters": [ + { + "name": "experiment_id", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('page_parameter.experiment_id')" + } + } + ], + "query_parameters": [], + "navigate_to_url": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "target": "self", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'<- Back to overview'" + }, + "variant": "link" + }, + { + "id": 8050, + "order": "1.00000000000000000000", + "type": "rating_input", + "parent_element_id": 8049, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "label": { + "mode": "simple", + "version": "0.1", + "formula": "'Potential'" + }, + "required": true, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9072')" + }, + "max_value": 6, + "color": "primary", + "rating_style": "star" + }, + { + "id": 8057, + "order": "1.00000000000000000000", + "type": "heading", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "concat(get('data_source.985.field_9065'),' (',get('data_source.985.field_9064'),')')" + }, + "level": 1 + }, + { + "id": 8058, + "order": "1.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'STATUS'" + }, + "format": "plain" + }, + { + "id": 8059, + "order": "1.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "2", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'PRIORITY SCORE'" + }, + "format": "plain" + }, + { + "id": 8060, + "order": "1.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'DOCUMENT'" + }, + "format": "plain" + }, + { + "id": 8061, + "order": "1.00000000000000000000", + "type": "image", + "parent_element_id": 8048, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "image": {} + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "image_source_type": "url", + "image_file_id": null, + "image_url": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.url')" + }, + "alt_text": { + "mode": "simple", + "version": "0.1", + "formula": "" + } + }, + { + "id": 8047, + "order": "2.00000000000000000000", + "type": "column", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 1, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 1, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "column_amount": 3, + "column_gap": 20, + "alignment": "top" + }, + { + "id": 8051, + "order": "2.00000000000000000000", + "type": "rating_input", + "parent_element_id": 8049, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "label": { + "mode": "simple", + "version": "0.1", + "formula": "'Complexity'" + }, + "required": true, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "concat(get('data_source.985.field_9072'),get('data_source.985.field_9074'))" + }, + "max_value": 6, + "color": "primary", + "rating_style": "star" + }, + { + "id": 8062, + "order": "2.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9066.value')" + }, + "format": "plain" + }, + { + "id": 8063, + "order": "2.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "2", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 24, + "body_text_color": "secondary" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 10, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9097')" + }, + "format": "plain" + }, + { + "id": 8064, + "order": "2.00000000000000000000", + "type": "link", + "parent_element_id": 8047, + "place_in_container": "1", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "navigation_type": "custom", + "navigate_to_page_id": null, + "page_parameters": [], + "query_parameters": [], + "navigate_to_url": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9080.0.url')" + }, + "target": "blank", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9080.0.visible_name')" + }, + "variant": "link" + }, + { + "id": 8065, + "order": "2.33333333333333348136", + "type": "heading", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'Images'" + }, + "level": 2 + }, + { + "id": 8048, + "order": "2.50000000000000000000", + "type": "repeat", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "data_source_id": 985, + "items_per_page": 20, + "button_load_more_label": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "schema_property": "field_9079", + "property_options": [], + "orientation": "horizontal", + "items_per_row": { + "tablet": 2, + "desktop": 5, + "smartphone": 2 + }, + "horizontal_gap": 30, + "vertical_gap": 0 + }, + { + "id": 8052, + "order": "3.00000000000000000000", + "type": "choice", + "parent_element_id": 8049, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "label": { + "mode": "simple", + "version": "0.1", + "formula": "'Above the fold'" + }, + "required": true, + "placeholder": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "default_value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9073.id')" + }, + "options": [], + "multiple": false, + "show_as_dropdown": false, + "option_type": "formulas", + "formula_value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source_context.985.field_9073.*.id')" + }, + "formula_name": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source_context.985.field_9073.*.value')" + } + }, + { + "id": 8066, + "order": "3.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'TEST FOCUS'" + }, + "format": "plain" + }, + { + "id": 8067, + "order": "3.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "2", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'CONFIDENCE SCORE'" + }, + "format": "plain" + }, + { + "id": 8068, + "order": "3.00000000000000000000", + "type": "heading", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'Update variant scores'" + }, + "level": 2 + }, + { + "id": 8049, + "order": "3.33333333333333348136", + "type": "form_container", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "button": { + "button_alignment": "center", + "button_horizontal_padding": 36 + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 20, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 20, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 50, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 50, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "color", + "style_background_color": "zuHhf", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "small", + "style_width_child": "normal", + "submit_button_label": { + "mode": "simple", + "version": "0.1", + "formula": "'Update variant scores'" + }, + "reset_initial_values_post_submission": false + }, + { + "id": 8069, + "order": "3.50000000000000000000", + "type": "heading", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 30, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'Other variants'" + }, + "level": 2 + }, + { + "id": 8053, + "order": "4.00000000000000000000", + "type": "choice", + "parent_element_id": 8049, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "label": { + "mode": "simple", + "version": "0.1", + "formula": "'Based on previous tests'" + }, + "required": true, + "placeholder": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "default_value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9075.id')" + }, + "options": [], + "multiple": false, + "show_as_dropdown": false, + "option_type": "formulas", + "formula_value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source_context.985.field_9075.*.id')" + }, + "formula_name": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source_context.985.field_9075.*.value')" + } + }, + { + "id": 8070, + "order": "4.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9068')" + }, + "format": "plain" + }, + { + "id": 8071, + "order": "4.00000000000000000000", + "type": "table", + "parent_element_id": null, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 0, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 20, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 20, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "data_source_id": 986, + "items_per_page": 20, + "button_load_more_label": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "schema_property": null, + "property_options": [], + "fields": [ + { + "uid": "44d2b11b-9ad7-41d9-8863-c4f219de5861", + "name": "ID", + "type": "link", + "styles": {}, + "config": { + "navigation_type": "page", + "navigate_to_page_id": 555, + "page_parameters": [ + { + "name": "experiment_id", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('page_parameter.experiment_id')" + } + }, + { + "name": "variant_id", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.id')" + } + } + ], + "query_parameters": [], + "navigate_to_url": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "target": "self", + "link_name": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9064')" + }, + "variant": "link" + } + }, + { + "uid": "baba143b-ba8e-426c-8e26-0e2ad7bec0f2", + "name": "Name", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9065')" + } + } + }, + { + "uid": "638f3966-eec1-4aa9-8523-72106b012dd8", + "name": "Conclusion", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9104')" + } + } + }, + { + "uid": "aa580312-40c6-464d-8a1f-184b357e73a2", + "name": "Potential", + "type": "rating", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9072')" + }, + "color": "primary", + "rating_style": "star", + "max_value": 5 + } + }, + { + "uid": "6ae0c644-7597-450e-afc5-fac568e73335", + "name": "Complexity", + "type": "rating", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9074')" + }, + "color": "primary", + "rating_style": "star", + "max_value": 5 + } + }, + { + "uid": "a950aaea-0a79-4453-a708-979a46857a66", + "name": "Above fold", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9073.value')" + } + } + }, + { + "uid": "1195f1b6-419f-40b6-86aa-0220cea57d2b", + "name": "Previous test", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9075.value')" + } + } + }, + { + "uid": "cb63d48a-7fa9-4812-9ed4-da3160f79634", + "name": "Quantitative", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9076.value')" + } + } + }, + { + "uid": "0533c122-6ce8-4af0-b2b4-d84f9dd09443", + "name": "Qualitative", + "type": "text", + "styles": {}, + "config": { + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('current_record.field_9077.value')" + } + } + } + ], + "orientation": { + "tablet": "horizontal", + "desktop": "horizontal", + "smartphone": "horizontal" + } + }, + { + "id": 8072, + "order": "4.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "2", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 24, + "body_text_color": "secondary" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 10, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9098')" + }, + "format": "plain" + }, + { + "id": 8054, + "order": "5.00000000000000000000", + "type": "choice", + "parent_element_id": 8049, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "label": { + "mode": "simple", + "version": "0.1", + "formula": "'Based on quantitative data'" + }, + "required": true, + "placeholder": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "default_value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9076.id')" + }, + "options": [], + "multiple": false, + "show_as_dropdown": false, + "option_type": "formulas", + "formula_value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source_context.985.field_9076.*.id')" + }, + "formula_name": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source_context.985.field_9076.*.value')" + } + }, + { + "id": 8073, + "order": "5.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'TEST CHANGE'" + }, + "format": "plain" + }, + { + "id": 8074, + "order": "5.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "2", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'OPPORTUNITY INDEX'" + }, + "format": "plain" + }, + { + "id": 8055, + "order": "6.00000000000000000000", + "type": "choice", + "parent_element_id": 8049, + "place_in_container": null, + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "label": { + "mode": "simple", + "version": "0.1", + "formula": "'Based on qualitative data'" + }, + "required": true, + "placeholder": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "default_value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9077.id')" + }, + "options": [], + "multiple": false, + "show_as_dropdown": false, + "option_type": "formulas", + "formula_value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source_context.985.field_9077.*.id')" + }, + "formula_name": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source_context.985.field_9077.*.value')" + } + }, + { + "id": 8075, + "order": "6.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9069')" + }, + "format": "plain" + }, + { + "id": 8076, + "order": "6.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "2", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 24, + "body_text_color": "secondary" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 10, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "concat(get('data_source.985.field_9104'),' (',get('data_source.985.field_9103'),')')" + }, + "format": "plain" + }, + { + "id": 8077, + "order": "7.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'DESIGN REQUIREMENTS '" + }, + "format": "plain" + }, + { + "id": 8078, + "order": "8.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9070')" + }, + "format": "plain" + }, + { + "id": 8079, + "order": "9.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'DEVICE'" + }, + "format": "plain" + }, + { + "id": 8080, + "order": "10.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9071.value')" + }, + "format": "plain" + }, + { + "id": 8081, + "order": "11.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": { + "typography": { + "body_font_size": 11, + "body_font_weight": "bold" + } + }, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 10, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 0, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "'COMMENTS'" + }, + "format": "plain" + }, + { + "id": 8082, + "order": "12.00000000000000000000", + "type": "text", + "parent_element_id": 8047, + "place_in_container": "0", + "css_classes": "", + "visibility": "all", + "visibility_condition": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "role_type": "allow_all", + "roles": [], + "styles": {}, + "style_border_top_color": "border", + "style_border_top_size": 0, + "style_padding_top": 2, + "style_margin_top": 0, + "style_border_bottom_color": "border", + "style_border_bottom_size": 0, + "style_padding_bottom": 10, + "style_margin_bottom": 0, + "style_border_left_color": "border", + "style_border_left_size": 0, + "style_padding_left": 0, + "style_margin_left": 0, + "style_border_right_color": "border", + "style_border_right_size": 0, + "style_padding_right": 0, + "style_margin_right": 0, + "style_background_radius": 0, + "style_border_radius": 0, + "style_background": "none", + "style_background_color": "#ffffffff", + "style_background_file_id": null, + "style_background_mode": "fill", + "style_width": "normal", + "style_width_child": "normal", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.field_9078')" + }, + "format": "plain" + } + ], + "data_sources": [ + { + "id": 985, + "name": "Get variant", + "order": "1.00000000000000000000", + "service": { + "id": 1758, + "integration_id": 184, + "type": "local_baserow_get_row", + "sample_data": null, + "table_id": 969, + "view_id": null, + "search_query": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "filter_type": "AND", + "filters": [], + "row_id": { + "mode": "simple", + "version": "0.1", + "formula": "get('page_parameter.variant_id')" + } + } + }, + { + "id": 986, + "name": "Related variants", + "order": "2.00000000000000000000", + "service": { + "id": 1759, + "integration_id": 184, + "type": "local_baserow_list_rows", + "sample_data": null, + "table_id": 969, + "view_id": 4427, + "sortings": [ + { + "field_id": 9103, + "order_by": "DESC" + } + ], + "search_query": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "filter_type": "AND", + "filters": [ + { + "field_id": 9067, + "type": "link_row_has", + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('page_parameter.experiment_id')" + }, + "value_is_formula": true + } + ], + "default_result_count": 20 + } + } + ], + "workflow_actions": [ + { + "id": 1001, + "type": "update_row", + "order": 1, + "page_id": 555, + "element_id": 8049, + "event": "submit", + "service": { + "id": 1760, + "integration_id": 184, + "type": "local_baserow_upsert_row", + "sample_data": null, + "table_id": 969, + "row_id": { + "mode": "simple", + "version": "0.1", + "formula": "get('data_source.985.id')" + }, + "field_mappings": [ + { + "field_id": 9064, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9065, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9066, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9067, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9068, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9069, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9070, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9071, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9072, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('form_data.8050')" + }, + "enabled": true + }, + { + "field_id": 9073, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('form_data.8052')" + }, + "enabled": true + }, + { + "field_id": 9074, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('form_data.8051')" + }, + "enabled": true + }, + { + "field_id": 9075, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('form_data.8053')" + }, + "enabled": true + }, + { + "field_id": 9076, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('form_data.8054')" + }, + "enabled": true + }, + { + "field_id": 9077, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "get('form_data.8055')" + }, + "enabled": true + }, + { + "field_id": 9080, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9079, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + }, + { + "field_id": 9078, + "value": { + "mode": "simple", + "version": "0.1", + "formula": "" + }, + "enabled": false + } + ] + } + }, + { + "id": 1002, + "type": "refresh_data_source", + "order": 2, + "page_id": 555, + "element_id": 8049, + "event": "submit", + "data_source_id": 985 + }, + { + "id": 1003, + "type": "refresh_data_source", + "order": 3, + "page_id": 555, + "element_id": 8049, + "event": "submit", + "data_source_id": 986 + } + ], + "visibility": "all", + "role_type": "allow_all", + "roles": [] + } + ], + "integrations": [ + { + "id": 184, + "name": "Local Baserow", + "order": "1.00000000000000000000", + "type": "local_baserow", + "authorized_user": null + } + ], + "theme": { + "primary_color": "#63513d", + "secondary_color": "#037cba", + "border_color": "#d7d8d9ff", + "main_success_color": "#12D452", + "main_warning_color": "#FCC74A", + "main_error_color": "#FF5A4A", + "custom_colors": [ + { + "name": "White", + "color": "#ffffff", + "value": "7r1vQ" + }, + { + "name": "Text", + "color": "#34220d", + "value": "whWF9" + }, + { + "name": "Background", + "color": "#f6f5f4", + "value": "zuHhf" + } + ], + "body_font_family": "arial", + "body_font_size": 14, + "body_font_weight": "regular", + "body_text_color": "whWF9", + "body_text_alignment": "left", + "heading_1_font_family": "inter", + "heading_1_font_size": 24, + "heading_1_font_weight": "bold", + "heading_1_text_color": "primary", + "heading_1_text_alignment": "left", + "heading_1_text_decoration": [ + false, + false, + false, + false + ], + "heading_2_font_family": "inter", + "heading_2_font_size": 20, + "heading_2_font_weight": "semi-bold", + "heading_2_text_color": "secondary", + "heading_2_text_alignment": "left", + "heading_2_text_decoration": [ + false, + false, + false, + false + ], + "heading_3_font_family": "inter", + "heading_3_font_size": 16, + "heading_3_font_weight": "medium", + "heading_3_text_color": "whWF9", + "heading_3_text_alignment": "left", + "heading_3_text_decoration": [ + false, + false, + false, + false + ], + "heading_4_font_family": "inter", + "heading_4_font_size": 16, + "heading_4_font_weight": "medium", + "heading_4_text_color": "whWF9", + "heading_4_text_alignment": "left", + "heading_4_text_decoration": [ + false, + false, + false, + false + ], + "heading_5_font_family": "inter", + "heading_5_font_size": 14, + "heading_5_font_weight": "regular", + "heading_5_text_color": "whWF9", + "heading_5_text_alignment": "left", + "heading_5_text_decoration": [ + false, + false, + false, + false + ], + "heading_6_font_family": "inter", + "heading_6_font_size": 14, + "heading_6_font_weight": "regular", + "heading_6_text_color": "whWF9", + "heading_6_text_alignment": "left", + "heading_6_text_decoration": [ + false, + false, + false, + false + ], + "button_font_family": "inter", + "button_font_size": 13, + "button_font_weight": "regular", + "button_alignment": "left", + "button_text_alignment": "center", + "button_width": "auto", + "button_background_color": "secondary", + "button_text_color": "#ffffffff", + "button_border_color": "border", + "button_border_size": 0, + "button_border_radius": 4, + "button_vertical_padding": 12, + "button_horizontal_padding": 12, + "button_hover_background_color": "#96baf6ff", + "button_hover_text_color": "#ffffffff", + "button_hover_border_color": "border", + "button_active_background_color": "#4783db", + "button_active_text_color": "#ffffffff", + "button_active_border_color": "#275d9f", + "image_alignment": "left", + "image_max_width": 100, + "image_max_height": null, + "image_border_radius": 0, + "image_constraint": "contain", + "page_background_color": "#ffffffff", + "page_background_file_id": null, + "page_background_mode": "tile", + "label_font_family": "inter", + "label_text_color": "primary", + "label_font_size": 13, + "label_font_weight": "medium", + "input_font_family": "inter", + "input_font_size": 13, + "input_font_weight": "regular", + "input_text_color": "whWF9", + "input_background_color": "#FFFFFFFF", + "input_border_color": "border", + "input_border_size": 1, + "input_border_radius": 0, + "input_vertical_padding": 8, + "input_horizontal_padding": 12, + "table_border_color": "border", + "table_border_size": 0, + "table_border_radius": 0, + "table_header_background_color": "zuHhf", + "table_header_text_color": "primary", + "table_header_font_size": 13, + "table_header_font_weight": "semi-bold", + "table_header_font_family": "inter", + "table_header_text_alignment": "left", + "table_cell_background_color": "transparent", + "table_cell_alternate_background_color": "transparent", + "table_cell_alignment": "left", + "table_cell_vertical_padding": 10, + "table_cell_horizontal_padding": 20, + "table_vertical_separator_color": "border", + "table_vertical_separator_size": 0, + "table_horizontal_separator_color": "border", + "table_horizontal_separator_size": 1, + "link_font_family": "inter", + "link_font_size": 13, + "link_font_weight": "regular", + "link_text_alignment": "left", + "link_text_color": "secondary", + "link_hover_text_color": "#96baf6ff", + "link_active_text_color": "#275d9f", + "link_default_text_decoration": [ + true, + false, + false, + false + ], + "link_hover_text_decoration": [ + true, + false, + false, + false + ], + "link_active_text_decoration": [ + true, + false, + false, + false + ] + }, + "user_sources": [], + "favicon_file": null, + "login_page": null, + "id": 345, + "name": "Rating variants", + "order": 3, + "type": "builder", + "scripts": [], + "custom_code": { + "css": "@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap');\n\n.ab-heading{\n font-family: 'Open sans'\n}", + "js": "" + } + } + ] +} \ No newline at end of file diff --git a/backend/templates/ab-testing.zip b/backend/templates/ab-testing.zip new file mode 100644 index 0000000000000000000000000000000000000000..0e464946d1b908f8610dbeaabe0286738a4311f1 GIT binary patch literal 82154 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