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" +}