From a11883ca0cf06839cb592e1446a56a42d895edea Mon Sep 17 00:00:00 2001
From: Bram Wiepjes <bramw@protonmail.com>
Date: Fri, 14 Mar 2025 11:15:29 +0000
Subject: [PATCH] [2/3] Import Airtable view colors

---
 .../contrib/database/airtable/constants.py    |  42 +++-
 .../database/airtable/import_report.py        |  10 +-
 .../contrib/database/airtable/registry.py     | 197 +++++++++++++++-
 .../airtable/test_airtable_column_types.py    |   8 +-
 .../airtable/test_airtable_view_types.py      | 211 ++++++++++++++++++
 .../feature/793_import_airtable_colors.json   |   8 +
 6 files changed, 457 insertions(+), 19 deletions(-)
 create mode 100644 changelog/entries/unreleased/feature/793_import_airtable_colors.json

diff --git a/backend/src/baserow/contrib/database/airtable/constants.py b/backend/src/baserow/contrib/database/airtable/constants.py
index f0d7478bd..39685d415 100644
--- a/backend/src/baserow/contrib/database/airtable/constants.py
+++ b/backend/src/baserow/contrib/database/airtable/constants.py
@@ -13,16 +13,42 @@ AIRTABLE_EXPORT_JOB_DOWNLOADING_BASE = "downloading-base"
 AIRTABLE_EXPORT_JOB_CONVERTING = "converting"
 AIRTABLE_EXPORT_JOB_DOWNLOADING_FILES = "downloading-files"
 AIRTABLE_BASEROW_COLOR_MAPPING = {
-    "blue": "blue",
-    "cyan": "light-blue",
-    "teal": "light-green",
-    "green": "green",
-    "yellow": "light-orange",
-    "orange": "orange",
+    "blue": "light-blue",
+    "cyan": "light-cyan",
+    "teal": "light-pink",  # Baserow doesn't have teal, so we're using the left-over color
+    "green": "light-green",
+    "yellow": "light-yellow",
+    "orange": "light-orange",
     "red": "light-red",
-    "pink": "red",
-    "purple": "dark-blue",
+    "purple": "light-purple",
     "gray": "light-gray",
+    "blueMedium": "blue",
+    "cyanMedium": "cyan",
+    "tealMedium": "pink",
+    "greenMedium": "green",
+    "yellowMedium": "yellow",
+    "orangeMedium": "orange",
+    "redMedium": "red",
+    "purpleMedium": "purple",
+    "grayMedium": "gray",
+    "blueDark": "dark-blue",
+    "cyanDark": "dark-cyan",
+    "tealDark": "dark-pink",
+    "greenDark": "dark-green",
+    "yellowDark": "dark-yellow",
+    "orangeDark": "dark-orange",
+    "redDark": "dark-red",
+    "purpleDark": "dark-purple",
+    "grayDark": "dark-gray",
+    "blueDarker": "darker-blue",
+    "cyanDarker": "darker-cyan",
+    "tealDarker": "darker-pink",
+    "greenDarker": "darker-green",
+    "yellowDarker": "darker-yellow",
+    "orangeDarker": "darker-orange",
+    "redDarker": "darker-red",
+    "purpleDarker": "darker-purple",
+    "grayDarker": "darker-gray",
 }
 AIRTABLE_NUMBER_FIELD_SEPARATOR_FORMAT_MAPPING = {
     "commaPeriod": "COMMA_PERIOD",
diff --git a/backend/src/baserow/contrib/database/airtable/import_report.py b/backend/src/baserow/contrib/database/airtable/import_report.py
index 2a24f5481..907a2e88f 100644
--- a/backend/src/baserow/contrib/database/airtable/import_report.py
+++ b/backend/src/baserow/contrib/database/airtable/import_report.py
@@ -25,17 +25,20 @@ SCOPE_VIEW_GROUP_BY = SelectOption(
 SCOPE_VIEW_FILTER = SelectOption(
     id="scope_view_filter", value="View filter", color="light-pink", order=6
 )
+SCOPE_VIEW_COLOR = SelectOption(
+    id="scope_view_color", value="View color", color="light-gray", order=7
+)
 SCOPE_VIEW_FIELD_OPTIONS = SelectOption(
     id="scope_view_field_options",
     value="View field options",
     color="light-purple",
-    order=7,
+    order=8,
 )
 SCOPE_AUTOMATIONS = SelectOption(
-    id="scope_automations", value="Automations", color="light-orange", order=8
+    id="scope_automations", value="Automations", color="light-orange", order=9
 )
 SCOPE_INTERFACES = SelectOption(
-    id="scope_interfaces", value="Interfaces", color="light-yellow", order=9
+    id="scope_interfaces", value="Interfaces", color="light-yellow", order=10
 )
 ALL_SCOPES = [
     SCOPE_FIELD,
@@ -44,6 +47,7 @@ ALL_SCOPES = [
     SCOPE_VIEW_SORT,
     SCOPE_VIEW_GROUP_BY,
     SCOPE_VIEW_FILTER,
+    SCOPE_VIEW_COLOR,
     SCOPE_AUTOMATIONS,
     SCOPE_INTERFACES,
 ]
diff --git a/backend/src/baserow/contrib/database/airtable/registry.py b/backend/src/baserow/contrib/database/airtable/registry.py
index 20aa4369c..01e7221e5 100644
--- a/backend/src/baserow/contrib/database/airtable/registry.py
+++ b/backend/src/baserow/contrib/database/airtable/registry.py
@@ -1,14 +1,25 @@
 from typing import Any, Dict, List, Optional, Tuple, Union
 
+from baserow_premium.views.decorator_types import LeftBorderColorDecoratorType
+from baserow_premium.views.decorator_value_provider_types import (
+    ConditionalColorValueProviderType,
+    SelectColorValueProviderType,
+)
+
 from baserow.contrib.database.airtable.config import AirtableImportConfig
-from baserow.contrib.database.airtable.constants import AIRTABLE_ASCENDING_MAP
+from baserow.contrib.database.airtable.constants import (
+    AIRTABLE_ASCENDING_MAP,
+    AIRTABLE_BASEROW_COLOR_MAPPING,
+)
 from baserow.contrib.database.airtable.exceptions import (
     AirtableSkipCellValue,
     AirtableSkipFilter,
 )
 from baserow.contrib.database.airtable.import_report import (
+    ERROR_TYPE_DATA_TYPE_MISMATCH,
     ERROR_TYPE_UNSUPPORTED_FEATURE,
     SCOPE_FIELD,
+    SCOPE_VIEW_COLOR,
     SCOPE_VIEW_FILTER,
     SCOPE_VIEW_GROUP_BY,
     SCOPE_VIEW_SORT,
@@ -27,6 +38,7 @@ from baserow.contrib.database.views.models import (
     SORT_ORDER_ASC,
     SORT_ORDER_DESC,
     View,
+    ViewDecoration,
     ViewFilter,
     ViewFilterGroup,
     ViewGroupBy,
@@ -492,6 +504,174 @@ class AirtableViewType(Instance):
             else:
                 return [baserow_filter], []
 
+    def get_select_column_decoration(
+        self,
+        field_mapping: dict,
+        view_type: ViewType,
+        row_id_mapping: Dict[str, Dict[str, int]],
+        raw_airtable_table: dict,
+        raw_airtable_view: dict,
+        raw_airtable_view_data: dict,
+        import_report: AirtableImportReport,
+    ) -> Optional[ViewDecoration]:
+        color_config = raw_airtable_view_data["colorConfig"]
+        select_column_id = color_config["selectColumnId"]
+
+        if select_column_id not in field_mapping:
+            column_name = get_airtable_column_name(raw_airtable_table, select_column_id)
+            import_report.add_failed(
+                raw_airtable_view["name"],
+                SCOPE_VIEW_COLOR,
+                raw_airtable_table["name"],
+                ERROR_TYPE_DATA_TYPE_MISMATCH,
+                f'The select field coloring was ignored in {raw_airtable_view["name"]} '
+                f"because {column_name} does not exist.",
+            )
+            return None
+
+        return ViewDecoration(
+            id=f"{raw_airtable_view['id']}_decoration",
+            view_id=raw_airtable_view["id"],
+            type=LeftBorderColorDecoratorType.type,
+            value_provider_type=SelectColorValueProviderType.type,
+            value_provider_conf={"field_id": select_column_id},
+            order=1,
+        )
+
+    def get_color_definitions_decoration(
+        self,
+        field_mapping: dict,
+        view_type: ViewType,
+        row_id_mapping: Dict[str, Dict[str, int]],
+        raw_airtable_table: dict,
+        raw_airtable_view: dict,
+        raw_airtable_view_data: dict,
+        import_report: AirtableImportReport,
+    ) -> Optional[ViewDecoration]:
+        color_config = raw_airtable_view_data["colorConfig"]
+        color_definitions = color_config["colorDefinitions"]
+        default_color = AIRTABLE_BASEROW_COLOR_MAPPING.get(
+            color_config.get("defaultColor", ""),
+            "",
+        )
+        baserow_colors = []
+
+        for color_definition in color_definitions:
+            filters, filter_groups = self.get_filters(
+                field_mapping,
+                row_id_mapping,
+                raw_airtable_view,
+                raw_airtable_table,
+                import_report,
+                color_definition,
+            )
+            # Pop the first group because that shouldn't be in Baserow, and the type is
+            # defined on the view.
+            root_group = filter_groups.pop(0)
+            color = AIRTABLE_BASEROW_COLOR_MAPPING.get(
+                color_definition.get("color", ""),
+                "blue",
+            )
+            baserow_colors.append(
+                {
+                    "filter_groups": [
+                        {
+                            "id": filter_group.id,
+                            "filter_type": filter_group.filter_type,
+                            "parent_group": (
+                                None
+                                if filter_group.parent_group_id == root_group.id
+                                else filter_group.parent_group_id
+                            ),
+                        }
+                        for filter_group in filter_groups
+                    ],
+                    "filters": [
+                        {
+                            "id": filter_object.id,
+                            "type": filter_object.type,
+                            "field": filter_object.field_id,
+                            "group": (
+                                None
+                                if filter_object.group_id == root_group.id
+                                else filter_object.group_id
+                            ),
+                            "value": filter_object.value,
+                        }
+                        for filter_object in filters
+                    ],
+                    "operator": root_group.filter_type,
+                    "color": color,
+                }
+            )
+
+        if default_color != "":
+            baserow_colors.append(
+                {
+                    "filter_groups": [],
+                    "filters": [],
+                    "operator": "AND",
+                    "color": default_color,
+                }
+            )
+
+        return ViewDecoration(
+            id=f"{raw_airtable_view['id']}_decoration",
+            view_id=raw_airtable_view["id"],
+            type=LeftBorderColorDecoratorType.type,
+            value_provider_type=ConditionalColorValueProviderType.type,
+            value_provider_conf={"colors": baserow_colors},
+            order=1,
+        )
+
+    def get_decorations(
+        self,
+        field_mapping: dict,
+        view_type: ViewType,
+        row_id_mapping: Dict[str, Dict[str, int]],
+        raw_airtable_table: dict,
+        raw_airtable_view: dict,
+        raw_airtable_view_data: dict,
+        import_report: AirtableImportReport,
+    ) -> List[ViewDecoration]:
+        """
+        Converts the raw Airtable color config into matching Baserow view decorations.
+        """
+
+        color_config = raw_airtable_view_data.get("colorConfig", None)
+
+        if not view_type.can_decorate or color_config is None:
+            return []
+
+        color_config_type = color_config.get("type", "")
+        decoration = None
+
+        if color_config_type == "selectColumn":
+            decoration = self.get_select_column_decoration(
+                field_mapping,
+                view_type,
+                row_id_mapping,
+                raw_airtable_table,
+                raw_airtable_view,
+                raw_airtable_view_data,
+                import_report,
+            )
+        elif color_config_type == "colorDefinitions":
+            decoration = self.get_color_definitions_decoration(
+                field_mapping,
+                view_type,
+                row_id_mapping,
+                raw_airtable_table,
+                raw_airtable_view,
+                raw_airtable_view_data,
+                import_report,
+            )
+
+        if decoration:
+            return [decoration]
+        else:
+            return []
+
     def to_serialized_baserow_view(
         self,
         field_mapping,
@@ -527,7 +707,7 @@ class AirtableViewType(Instance):
                 import_report,
                 filters_object,
             )
-            # Pop the first group because that shouldn't in Baserow, and the type is
+            # Pop the first group because that shouldn't be in Baserow, and the type is
             # defined on the view.
             view.filter_type = filter_groups.pop(0).filter_type
 
@@ -547,6 +727,15 @@ class AirtableViewType(Instance):
             raw_airtable_view_data,
             import_report,
         )
+        decorations = self.get_decorations(
+            field_mapping,
+            view_type,
+            row_id_mapping,
+            raw_airtable_table,
+            raw_airtable_view,
+            raw_airtable_view_data,
+            import_report,
+        )
 
         view.get_field_options = lambda *args, **kwargs: []
         view._prefetched_objects_cache = {
@@ -554,7 +743,7 @@ class AirtableViewType(Instance):
             "filter_groups": filter_groups,
             "viewsort_set": sorts,
             "viewgroupby_set": group_bys,
-            "viewdecoration_set": [],
+            "viewdecoration_set": decorations,
         }
         view = self.prepare_view_object(
             field_mapping,
@@ -587,7 +776,7 @@ class AirtableViewType(Instance):
         Note that the common properties like name, filters, sorts, etc are added by
         default depending on the Baserow view support for it.
 
-        :param field_mapping: @TODO
+        :param field_mapping: A dict containing all the imported fields.
         :param view: The view object that must be prepared.
         :param raw_airtable_table: The raw Airtable table data related to the column.
         :param raw_airtable_view: The raw Airtable view values that must be
diff --git a/backend/tests/baserow/contrib/database/airtable/test_airtable_column_types.py b/backend/tests/baserow/contrib/database/airtable/test_airtable_column_types.py
index f1b23f63a..e50889aac 100644
--- a/backend/tests/baserow/contrib/database/airtable/test_airtable_column_types.py
+++ b/backend/tests/baserow/contrib/database/airtable/test_airtable_column_types.py
@@ -1413,11 +1413,11 @@ def test_airtable_import_multi_select_column(
     assert len(select_options) == 2
     assert select_options[0].id == "fldURNo0cvi6YWYcYj1_selEOJmenvqEd6pndFQ"
     assert select_options[0].value == "Option 1"
-    assert select_options[0].color == "blue"
+    assert select_options[0].color == "light-blue"
     assert select_options[0].order == 1
     assert select_options[1].id == "fldURNo0cvi6YWYcYj1_sel5ekvuoNVvl03olMO"
     assert select_options[1].value == "Option 2"
-    assert select_options[1].color == "light-blue"
+    assert select_options[1].color == "light-cyan"
     assert select_options[1].order == 0
 
 
@@ -2368,11 +2368,11 @@ def test_airtable_import_select_column(
     assert len(select_options) == 2
     assert select_options[0].id == "fldRd2Vkzgsf6X4z6B4_selbh6rEWaaiyQvWyfg"
     assert select_options[0].value == "Option A"
-    assert select_options[0].color == "blue"
+    assert select_options[0].color == "light-blue"
     assert select_options[0].order == 0
     assert select_options[1].id == "fldRd2Vkzgsf6X4z6B4_selvZgpWhbkeRVphROT"
     assert select_options[1].value == "Option B"
-    assert select_options[1].color == "light-blue"
+    assert select_options[1].color == "light-cyan"
     assert select_options[1].order == 1
 
 
diff --git a/backend/tests/baserow/contrib/database/airtable/test_airtable_view_types.py b/backend/tests/baserow/contrib/database/airtable/test_airtable_view_types.py
index 835c67217..cd7f42b1b 100644
--- a/backend/tests/baserow/contrib/database/airtable/test_airtable_view_types.py
+++ b/backend/tests/baserow/contrib/database/airtable/test_airtable_view_types.py
@@ -6,6 +6,7 @@ import pytest
 
 from baserow.contrib.database.airtable.config import AirtableImportConfig
 from baserow.contrib.database.airtable.import_report import (
+    SCOPE_VIEW_COLOR,
     SCOPE_VIEW_GROUP_BY,
     SCOPE_VIEW_SORT,
     AirtableImportReport,
@@ -133,6 +134,62 @@ RAW_VIEW_DATA_GROUPS = [
         "emptyGroupState": "hidden",
     }
 ]
+RAW_VIEW_COLOR_CONFIG_SELECT_COLUMN = {
+    "type": "selectColumn",
+    "selectColumnId": "fldwSc9PqedIhTSqhi1",
+    "colorDefinitions": None,
+    "defaultColor": None,
+}
+RAW_VIEW_COLOR_CONFIG_COLOR_DEFINITIONS = {
+    "type": "colorDefinitions",
+    "colorDefinitions": [
+        {
+            "filterSet": [
+                {
+                    "id": "fltp2gabc8P91234f",
+                    "columnId": "fldwSc9PqedIhTSqhi1",
+                    "operator": "isNotEmpty",
+                    "value": None,
+                },
+                {
+                    "id": "flthuYL0uubbDF2Xy",
+                    "type": "nested",
+                    "conjunction": "and",
+                    "filterSet": [
+                        {
+                            "id": "flt70g1l245672xRi",
+                            "columnId": "fldwSc9PqedIhTSqhi1",
+                            "operator": "!=",
+                            "value": "test",
+                        },
+                        {
+                            "id": "fltVg238719fbIKqC",
+                            "columnId": "fldwSc9PqedIhTSqhi2",
+                            "operator": "!=",
+                            "value": "test2",
+                        },
+                        {
+                            "id": "flthuYL0uubbDF2Xz",
+                            "type": "nested",
+                            "conjunction": "or",
+                            "filterSet": [
+                                {
+                                    "id": "flt70g1l245672xRi",
+                                    "columnId": "fldwSc9PqedIhTSqhi1",
+                                    "operator": "!=",
+                                    "value": "test",
+                                },
+                            ],
+                        },
+                    ],
+                },
+            ],
+            "conjunction": "or",
+            "color": "yellow",
+        }
+    ],
+    "defaultColor": "teal",
+}
 
 
 def test_import_grid_view():
@@ -508,3 +565,157 @@ def test_import_grid_view_filters_and_groups():
     assert serialized_view["filter_groups"] == [
         {"id": "flthuYL0uubbDF2Xy", "filter_type": "AND", "parent_group": None}
     ]
+
+
+@pytest.mark.django_db
+def test_import_grid_view_color_config_select_column_not_existing_column():
+    view_data = deepcopy(RAW_AIRTABLE_VIEW_DATA)
+    field_mapping = deepcopy(FIELD_MAPPING)
+    for field_object in field_mapping.values():
+        field_object["baserow_field"].content_type = ContentType.objects.get_for_model(
+            field_object["baserow_field"]
+        )
+
+    view_data["colorConfig"] = deepcopy(RAW_VIEW_COLOR_CONFIG_SELECT_COLUMN)
+    view_data["colorConfig"]["selectColumnId"] = "fld123"
+
+    airtable_view_type = airtable_view_type_registry.get("grid")
+    import_report = AirtableImportReport()
+    serialized_view = airtable_view_type.to_serialized_baserow_view(
+        field_mapping,
+        ROW_ID_MAPPING,
+        RAW_AIRTABLE_TABLE,
+        RAW_AIRTABLE_VIEW,
+        view_data,
+        AirtableImportConfig(),
+        import_report,
+    )
+    assert len(import_report.items) == 1
+    assert import_report.items[0].object_name == "Grid view"
+    assert import_report.items[0].scope == SCOPE_VIEW_COLOR
+    assert import_report.items[0].table == "Data"
+
+
+@pytest.mark.django_db
+def test_import_grid_view_color_config_select_column():
+    view_data = deepcopy(RAW_AIRTABLE_VIEW_DATA)
+    field_mapping = deepcopy(FIELD_MAPPING)
+    for field_object in field_mapping.values():
+        field_object["baserow_field"].content_type = ContentType.objects.get_for_model(
+            field_object["baserow_field"]
+        )
+
+    view_data["colorConfig"] = RAW_VIEW_COLOR_CONFIG_SELECT_COLUMN
+
+    airtable_view_type = airtable_view_type_registry.get("grid")
+    import_report = AirtableImportReport()
+    serialized_view = airtable_view_type.to_serialized_baserow_view(
+        field_mapping,
+        ROW_ID_MAPPING,
+        RAW_AIRTABLE_TABLE,
+        RAW_AIRTABLE_VIEW,
+        view_data,
+        AirtableImportConfig(),
+        import_report,
+    )
+    assert len(import_report.items) == 0
+
+    assert serialized_view["decorations"] == [
+        {
+            "id": "viwcpYeEpAs6kZspktV_decoration",
+            "type": "left_border_color",
+            "value_provider_type": "single_select_color",
+            "value_provider_conf": {"field_id": "fldwSc9PqedIhTSqhi1"},
+            "order": 1,
+        }
+    ]
+
+
+@pytest.mark.django_db
+def test_import_grid_view_color_config_color_definitions():
+    view_data = deepcopy(RAW_AIRTABLE_VIEW_DATA)
+    field_mapping = deepcopy(FIELD_MAPPING)
+    for field_object in field_mapping.values():
+        field_object["baserow_field"].content_type = ContentType.objects.get_for_model(
+            field_object["baserow_field"]
+        )
+
+    view_data["colorConfig"] = RAW_VIEW_COLOR_CONFIG_COLOR_DEFINITIONS
+
+    airtable_view_type = airtable_view_type_registry.get("grid")
+    import_report = AirtableImportReport()
+    serialized_view = airtable_view_type.to_serialized_baserow_view(
+        field_mapping,
+        ROW_ID_MAPPING,
+        RAW_AIRTABLE_TABLE,
+        RAW_AIRTABLE_VIEW,
+        view_data,
+        AirtableImportConfig(),
+        import_report,
+    )
+    assert len(import_report.items) == 0
+
+    assert serialized_view["decorations"] == [
+        {
+            "id": "viwcpYeEpAs6kZspktV_decoration",
+            "type": "left_border_color",
+            "value_provider_type": "conditional_color",
+            "value_provider_conf": {
+                "colors": [
+                    {
+                        "filter_groups": [
+                            {
+                                "id": "flthuYL0uubbDF2Xy",
+                                "filter_type": "AND",
+                                "parent_group": None,
+                            },
+                            {
+                                "id": "flthuYL0uubbDF2Xz",
+                                "filter_type": "OR",
+                                "parent_group": "flthuYL0uubbDF2Xy",
+                            },
+                        ],
+                        "filters": [
+                            {
+                                "id": "fltp2gabc8P91234f",
+                                "type": "not_empty",
+                                "field": "fldwSc9PqedIhTSqhi1",
+                                "group": None,
+                                "value": "",
+                            },
+                            {
+                                "id": "flt70g1l245672xRi",
+                                "type": "not_equal",
+                                "field": "fldwSc9PqedIhTSqhi1",
+                                "group": "flthuYL0uubbDF2Xy",
+                                "value": "test",
+                            },
+                            {
+                                "id": "fltVg238719fbIKqC",
+                                "type": "not_equal",
+                                "field": "fldwSc9PqedIhTSqhi2",
+                                "group": "flthuYL0uubbDF2Xy",
+                                "value": "test2",
+                            },
+                            {
+                                "id": "flt70g1l245672xRi",
+                                "type": "not_equal",
+                                "field": "fldwSc9PqedIhTSqhi1",
+                                "group": "flthuYL0uubbDF2Xz",
+                                "value": "test",
+                            },
+                        ],
+                        "operator": "OR",
+                        "color": "light-yellow",
+                    },
+                    {
+                        "filter_groups": [],
+                        "filters": [],
+                        "operator": "AND",
+                        "color": "light-pink",
+                    },
+                ]
+            },
+            "order": 1,
+        }
+    ]
diff --git a/changelog/entries/unreleased/feature/793_import_airtable_colors.json b/changelog/entries/unreleased/feature/793_import_airtable_colors.json
new file mode 100644
index 000000000..1ff5cbf04
--- /dev/null
+++ b/changelog/entries/unreleased/feature/793_import_airtable_colors.json
@@ -0,0 +1,8 @@
+{
+  "type": "feature",
+  "message": "Import Airtable view colors.",
+  "domain": "database",
+  "issue_number": 793,
+  "bullet_points": [],
+  "created_at": "2025-03-13"
+}