diff --git a/label_studio/core/settings/base.py b/label_studio/core/settings/base.py
index ee8349a4d6d7..8cee4c21072b 100644
--- a/label_studio/core/settings/base.py
+++ b/label_studio/core/settings/base.py
@@ -776,7 +776,10 @@ def collect_versions_dummy(**kwargs):
# By default, we disallow filters with foreign keys in data manager for security reasons.
# Add to this list (either here in code, or via the env) to allow specific filters that rely on foreign keys.
DATA_MANAGER_FILTER_ALLOWLIST = list(
- set(get_env_list('DATA_MANAGER_FILTER_ALLOWLIST') + ['updated_by__active_organization'])
+ set(
+ get_env_list('DATA_MANAGER_FILTER_ALLOWLIST')
+ + ['updated_by__active_organization', 'annotations__completed_by']
+ )
)
if ENABLE_CSP := get_bool_env('ENABLE_CSP', True):
diff --git a/label_studio/data_manager/managers.py b/label_studio/data_manager/managers.py
index e1f2da9dc2c0..bbfccb338ed6 100644
--- a/label_studio/data_manager/managers.py
+++ b/label_studio/data_manager/managers.py
@@ -3,6 +3,7 @@
import logging
import re
from datetime import datetime
+from functools import reduce
from typing import ClassVar
import ujson as json
@@ -262,193 +263,213 @@ def apply_filters(queryset, filters, project, request):
return queryset
# convert conjunction to orm statement
- filter_expressions = []
custom_filter_expressions = load_func(settings.DATA_MANAGER_CUSTOM_FILTER_EXPRESSIONS)
- for _filter in filters.items:
+ # combine child filters with their parent in the same filter expression
+ filter_line_expressions: list[list[Q]] = []
+ for parent_filter in filters.items:
+ filter_line = [parent_filter, parent_filter.child_filter] if parent_filter.child_filter else [parent_filter]
+ filter_expressions: list[Q] = []
- # we can also have annotations filters
- if not _filter.filter.startswith('filter:tasks:') or _filter.value is None:
- continue
+ for _filter in filter_line:
+ is_child_filter = parent_filter.child_filter is not None and _filter is parent_filter.child_filter
- # django orm loop expression attached to column name
- preprocess_field_name = load_func(settings.PREPROCESS_FIELD_NAME)
- field_name, _ = preprocess_field_name(_filter.filter, project)
-
- # filter pre-processing, value type conversion, etc..
- preprocess_filter = load_func(settings.DATA_MANAGER_PREPROCESS_FILTER)
- _filter = preprocess_filter(_filter, field_name)
-
- # custom expressions for enterprise
- filter_expression = custom_filter_expressions(_filter, field_name, project, request=request)
- if filter_expression:
- filter_expressions.append(filter_expression)
- continue
-
- # annotators
- result = add_user_filter(field_name == 'annotators', 'annotations__completed_by', _filter, filter_expressions)
- if result == 'continue':
- continue
-
- # updated_by
- result = add_user_filter(field_name == 'updated_by', 'updated_by', _filter, filter_expressions)
- if result == 'continue':
- continue
-
- # annotations results & predictions results
- if field_name in ['annotations_results', 'predictions_results']:
- result = add_result_filter(field_name, _filter, filter_expressions, project)
- if result == 'exit':
- return queryset.none()
- elif result == 'continue':
+ # we can also have annotations filters
+ if not _filter.filter.startswith('filter:tasks:') or _filter.value is None:
continue
- # annotation ids
- if field_name == 'annotations_ids':
- field_name = 'annotations__id'
- if 'contains' in _filter.operator:
- # convert string like "1 2,3" => [1,2,3]
- _filter.value = [int(value) for value in re.split(',|;| ', _filter.value) if value and value.isdigit()]
- _filter.operator = 'in_list' if _filter.operator == 'contains' else 'not_in_list'
- elif 'equal' in _filter.operator:
- if not _filter.value.isdigit():
- _filter.value = 0
-
- # predictions model versions
- if field_name == 'predictions_model_versions' and _filter.operator == Operator.CONTAINS:
- q = Q()
- for value in _filter.value:
- q |= Q(predictions__model_version__contains=value)
- filter_expressions.append(q)
- continue
- elif field_name == 'predictions_model_versions' and _filter.operator == Operator.NOT_CONTAINS:
- q = Q()
- for value in _filter.value:
- q &= ~Q(predictions__model_version__contains=value)
- filter_expressions.append(q)
- continue
- elif field_name == 'predictions_model_versions' and _filter.operator == Operator.EMPTY:
- value = cast_bool_from_str(_filter.value)
- filter_expressions.append(Q(predictions__model_version__isnull=value))
- continue
-
- # use other name because of model names conflict
- if field_name == 'file_upload':
- field_name = 'file_upload_field'
-
- # annotate with cast to number if need
- if _filter.type == 'Number' and field_name.startswith('data__'):
- json_field = field_name.replace('data__', '')
- queryset = queryset.annotate(
- **{
- f'filter_{json_field.replace("$undefined$", "undefined")}': Cast(
- KeyTextTransform(json_field, 'data'), output_field=FloatField()
- )
- }
+ # django orm loop expression attached to column name
+ preprocess_field_name = load_func(settings.PREPROCESS_FIELD_NAME)
+ field_name, _ = preprocess_field_name(_filter.filter, project)
+
+ # filter pre-processing, value type conversion, etc..
+ preprocess_filter = load_func(settings.DATA_MANAGER_PREPROCESS_FILTER)
+ _filter = preprocess_filter(_filter, field_name)
+
+ # custom expressions for enterprise
+ filter_expression = custom_filter_expressions(
+ _filter,
+ field_name,
+ project,
+ request=request,
+ is_child_filter=is_child_filter,
)
- clean_field_name = f'filter_{json_field.replace("$undefined$", "undefined")}'
- else:
- clean_field_name = field_name
+ if filter_expression:
+ filter_expressions.append(filter_expression)
+ continue
- # special case: predictions, annotations, cancelled --- for them 0 is equal to is_empty=True
- if (
- clean_field_name in ('total_predictions', 'total_annotations', 'cancelled_annotations')
- and _filter.operator == 'empty'
- ):
- _filter.operator = 'equal' if cast_bool_from_str(_filter.value) else 'not_equal'
- _filter.value = 0
-
- # get type of annotated field
- value_type = 'str'
- if queryset.exists():
- value_type = type(queryset.values_list(field_name, flat=True)[0]).__name__
-
- if (value_type == 'list' or value_type == 'tuple') and 'equal' in _filter.operator:
- raise Exception('Not supported filter type')
-
- # special case: for strings empty is "" or null=True
- if _filter.type in ('String', 'Unknown') and _filter.operator == 'empty':
- value = cast_bool_from_str(_filter.value)
- if value: # empty = true
- q = Q(Q(**{field_name: None}) | Q(**{field_name + '__isnull': True}))
- if value_type == 'str':
- q |= Q(**{field_name: ''})
- if value_type == 'list':
- q = Q(**{field_name: [None]})
-
- else: # empty = false
- q = Q(~Q(**{field_name: None}) & ~Q(**{field_name + '__isnull': True}))
- if value_type == 'str':
- q &= ~Q(**{field_name: ''})
- if value_type == 'list':
- q = ~Q(**{field_name: [None]})
-
- filter_expressions.append(q)
- continue
-
- # regex pattern check
- elif _filter.operator == 'regex':
- try:
- re.compile(pattern=str(_filter.value))
- except Exception as e:
- logger.info('Incorrect regex for filter: %s: %s', _filter.value, str(e))
- return queryset.none()
-
- # append operator
- field_name = f"{clean_field_name}{operators.get(_filter.operator, '')}"
-
- # in
- if _filter.operator == 'in':
- cast_value(_filter)
- filter_expressions.append(
- Q(
- **{
- f'{field_name}__gte': _filter.value.min,
- f'{field_name}__lte': _filter.value.max,
- }
- ),
+ # annotators
+ result = add_user_filter(
+ field_name == 'annotators', 'annotations__completed_by', _filter, filter_expressions
)
+ if result == 'continue':
+ continue
- # not in
- elif _filter.operator == 'not_in':
- cast_value(_filter)
- filter_expressions.append(
- ~Q(
+ # updated_by
+ result = add_user_filter(field_name == 'updated_by', 'updated_by', _filter, filter_expressions)
+ if result == 'continue':
+ continue
+
+ # annotations results & predictions results
+ if field_name in ['annotations_results', 'predictions_results']:
+ result = add_result_filter(field_name, _filter, filter_expressions, project)
+ if result == 'exit':
+ return queryset.none()
+ elif result == 'continue':
+ continue
+
+ # annotation ids
+ if field_name == 'annotations_ids':
+ field_name = 'annotations__id'
+ if 'contains' in _filter.operator:
+ # convert string like "1 2,3" => [1,2,3]
+ _filter.value = [
+ int(value) for value in re.split(',|;| ', _filter.value) if value and value.isdigit()
+ ]
+ _filter.operator = 'in_list' if _filter.operator == 'contains' else 'not_in_list'
+ elif 'equal' in _filter.operator:
+ if not _filter.value.isdigit():
+ _filter.value = 0
+
+ # predictions model versions
+ if field_name == 'predictions_model_versions' and _filter.operator == Operator.CONTAINS:
+ q = Q()
+ for value in _filter.value:
+ q |= Q(predictions__model_version__contains=value)
+ filter_expressions.append(q)
+ continue
+ elif field_name == 'predictions_model_versions' and _filter.operator == Operator.NOT_CONTAINS:
+ q = Q()
+ for value in _filter.value:
+ q &= ~Q(predictions__model_version__contains=value)
+ filter_expressions.append(q)
+ continue
+ elif field_name == 'predictions_model_versions' and _filter.operator == Operator.EMPTY:
+ value = cast_bool_from_str(_filter.value)
+ filter_expressions.append(Q(predictions__model_version__isnull=value))
+ continue
+
+ # use other name because of model names conflict
+ if field_name == 'file_upload':
+ field_name = 'file_upload_field'
+
+ # annotate with cast to number if need
+ if _filter.type == 'Number' and field_name.startswith('data__'):
+ json_field = field_name.replace('data__', '')
+ queryset = queryset.annotate(
**{
- f'{field_name}__gte': _filter.value.min,
- f'{field_name}__lte': _filter.value.max,
+ f'filter_{json_field.replace("$undefined$", "undefined")}': Cast(
+ KeyTextTransform(json_field, 'data'), output_field=FloatField()
+ )
}
- ),
- )
+ )
+ clean_field_name = f'filter_{json_field.replace("$undefined$", "undefined")}'
+ else:
+ clean_field_name = field_name
+
+ # special case: predictions, annotations, cancelled --- for them 0 is equal to is_empty=True
+ if (
+ clean_field_name in ('total_predictions', 'total_annotations', 'cancelled_annotations')
+ and _filter.operator == 'empty'
+ ):
+ _filter.operator = 'equal' if cast_bool_from_str(_filter.value) else 'not_equal'
+ _filter.value = 0
+
+ # get type of annotated field
+ value_type = 'str'
+ if queryset.exists():
+ value_type = type(queryset.values_list(field_name, flat=True)[0]).__name__
+
+ if (value_type == 'list' or value_type == 'tuple') and 'equal' in _filter.operator:
+ raise Exception('Not supported filter type')
+
+ # special case: for strings empty is "" or null=True
+ if _filter.type in ('String', 'Unknown') and _filter.operator == 'empty':
+ value = cast_bool_from_str(_filter.value)
+ if value: # empty = true
+ q = Q(Q(**{field_name: None}) | Q(**{field_name + '__isnull': True}))
+ if value_type == 'str':
+ q |= Q(**{field_name: ''})
+ if value_type == 'list':
+ q = Q(**{field_name: [None]})
+
+ else: # empty = false
+ q = Q(~Q(**{field_name: None}) & ~Q(**{field_name + '__isnull': True}))
+ if value_type == 'str':
+ q &= ~Q(**{field_name: ''})
+ if value_type == 'list':
+ q = ~Q(**{field_name: [None]})
+
+ filter_expressions.append(q)
+ continue
- # in list
- elif _filter.operator == 'in_list':
- filter_expressions.append(
- Q(**{f'{field_name}__in': _filter.value}),
- )
+ # regex pattern check
+ elif _filter.operator == 'regex':
+ try:
+ re.compile(pattern=str(_filter.value))
+ except Exception as e:
+ logger.info('Incorrect regex for filter: %s: %s', _filter.value, str(e))
+ return queryset.none()
+
+ # append operator
+ field_name = f"{clean_field_name}{operators.get(_filter.operator, '')}"
+
+ # in
+ if _filter.operator == 'in':
+ cast_value(_filter)
+ filter_expressions.append(
+ Q(
+ **{
+ f'{field_name}__gte': _filter.value.min,
+ f'{field_name}__lte': _filter.value.max,
+ }
+ ),
+ )
- # not in list
- elif _filter.operator == 'not_in_list':
- filter_expressions.append(
- ~Q(**{f'{field_name}__in': _filter.value}),
- )
+ # not in
+ elif _filter.operator == 'not_in':
+ cast_value(_filter)
+ filter_expressions.append(
+ ~Q(
+ **{
+ f'{field_name}__gte': _filter.value.min,
+ f'{field_name}__lte': _filter.value.max,
+ }
+ ),
+ )
+
+ # in list
+ elif _filter.operator == 'in_list':
+ filter_expressions.append(
+ Q(**{f'{field_name}__in': _filter.value}),
+ )
+
+ # not in list
+ elif _filter.operator == 'not_in_list':
+ filter_expressions.append(
+ ~Q(**{f'{field_name}__in': _filter.value}),
+ )
+
+ # empty
+ elif _filter.operator == 'empty':
+ if cast_bool_from_str(_filter.value):
+ filter_expressions.append(Q(**{field_name: True}))
+ else:
+ filter_expressions.append(~Q(**{field_name: True}))
- # empty
- elif _filter.operator == 'empty':
- if cast_bool_from_str(_filter.value):
- filter_expressions.append(Q(**{field_name: True}))
+ # starting from not_
+ elif _filter.operator.startswith('not_'):
+ cast_value(_filter)
+ filter_expressions.append(~Q(**{field_name: _filter.value}))
+
+ # all others
else:
- filter_expressions.append(~Q(**{field_name: True}))
+ cast_value(_filter)
+ filter_expressions.append(Q(**{field_name: _filter.value}))
- # starting from not_
- elif _filter.operator.startswith('not_'):
- cast_value(_filter)
- filter_expressions.append(~Q(**{field_name: _filter.value}))
+ filter_line_expressions.append(filter_expressions)
- # all others
- else:
- cast_value(_filter)
- filter_expressions.append(Q(**{field_name: _filter.value}))
+ resolved_filter_lines = [reduce(lambda x, y: x & y, fle) for fle in filter_line_expressions]
"""WARNING: Stringifying filter_expressions will evaluate the (sub)queryset.
Do not use a log in the following manner:
@@ -458,12 +479,12 @@ def apply_filters(queryset, filters, project, request):
"""
if filters.conjunction == ConjunctionEnum.OR:
result_filter = Q()
- for filter_expression in filter_expressions:
- result_filter.add(filter_expression, Q.OR)
+ for resolved_filter in resolved_filter_lines:
+ result_filter.add(resolved_filter, Q.OR)
queryset = queryset.filter(result_filter)
else:
- for filter_expression in filter_expressions:
- queryset = queryset.filter(filter_expression)
+ for resolved_filter in resolved_filter_lines:
+ queryset = queryset.filter(resolved_filter)
return queryset
diff --git a/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py b/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py
new file mode 100644
index 000000000000..bcae9ad9c9e7
--- /dev/null
+++ b/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.1.10 on 2025-06-24 03:22
+
+import django.db.models.deletion
+import django_migration_linter as linter
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("data_manager", "0012_alter_view_user"),
+ ]
+
+ operations = [
+ linter.IgnoreMigration(),
+ migrations.AddField(
+ model_name="filter",
+ name="parent",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Optional parent filter to create one-level hierarchy (child filters are AND-merged with parent)",
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="children",
+ to="data_manager.filter",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="filter",
+ name="index",
+ field=models.IntegerField(
+ blank=True,
+ default=None,
+ help_text="Display order among root filters only",
+ null=True,
+ verbose_name="index",
+ ),
+ ),
+ ]
diff --git a/label_studio/data_manager/migrations/0014_merge_20250701_2259.py b/label_studio/data_manager/migrations/0014_merge_20250701_2259.py
new file mode 100644
index 000000000000..9247c9211682
--- /dev/null
+++ b/label_studio/data_manager/migrations/0014_merge_20250701_2259.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.1.10 on 2025-07-01 22:59
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("data_manager", "0013_cleanup_inconsistent_filtergroup_20250624_2119"),
+ ("data_manager", "0013_filter_parent_alter_filter_index"),
+ ]
+
+ operations = []
diff --git a/label_studio/data_manager/models.py b/label_studio/data_manager/models.py
index 35859d581af1..ca6d41e0c828 100644
--- a/label_studio/data_manager/models.py
+++ b/label_studio/data_manager/models.py
@@ -50,20 +50,14 @@ class Meta:
class View(ViewBaseModel, ProjectViewMixin):
def get_prepare_tasks_params(self, add_selected_items=False):
+ # Import here to avoid circular imports
+ from data_manager.serializers import FilterGroupSerializer
+
# convert filters to PrepareParams structure
filters = None
if self.filter_group:
- items = []
- for f in self.filter_group.filters.all():
- items.append(
- dict(
- filter=f.column,
- operator=f.operator,
- type=f.type,
- value=f.value,
- )
- )
- filters = dict(conjunction=self.filter_group.conjunction, items=items)
+ serializer = FilterGroupSerializer()
+ filters = serializer.to_representation(self.filter_group)
ordering = self.ordering
if not ordering:
@@ -86,7 +80,24 @@ class FilterGroup(models.Model):
class Filter(models.Model):
- index = models.IntegerField(_('index'), default=0, help_text='To keep filter order')
+ # Optional reference to a parent filter. We only allow **one** level of nesting.
+ parent = models.ForeignKey(
+ 'self',
+ on_delete=models.CASCADE,
+ related_name='children',
+ null=True,
+ blank=True,
+ help_text='Optional parent filter to create one-level hierarchy (child filters are AND-merged with parent)',
+ )
+
+ # `index` is now only meaningful for **root** filters (parent is NULL)
+ index = models.IntegerField(
+ _('index'),
+ null=True,
+ blank=True,
+ default=None,
+ help_text='Display order among root filters only',
+ )
column = models.CharField(_('column'), max_length=1024, help_text='Field name')
type = models.CharField(_('type'), max_length=1024, help_text='Field type')
operator = models.CharField(_('operator'), max_length=1024, help_text='Filter operator')
diff --git a/label_studio/data_manager/prepare_params.py b/label_studio/data_manager/prepare_params.py
index 95f8e849ee0e..131daa3ac25d 100644
--- a/label_studio/data_manager/prepare_params.py
+++ b/label_studio/data_manager/prepare_params.py
@@ -12,6 +12,8 @@ class FilterIn(BaseModel):
class Filter(BaseModel):
+ child_filter: Optional['Filter'] = None
+
filter: str
operator: str
type: str
diff --git a/label_studio/data_manager/serializers.py b/label_studio/data_manager/serializers.py
index 470f40ffa972..d2ab3ad80a1c 100644
--- a/label_studio/data_manager/serializers.py
+++ b/label_studio/data_manager/serializers.py
@@ -21,7 +21,35 @@
from label_studio.core.utils.common import round_floats
+class ChildFilterSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Filter
+ fields = '__all__'
+
+ def to_representation(self, value):
+ parent = self.parent # the owning FilterSerializer instance
+ serializer = parent.__class__(instance=value, context=self.context)
+ return serializer.data
+
+ def to_internal_value(self, data):
+ """Allow ChildFilterSerializer to be writable.
+
+ We instantiate the *parent* serializer class (which in this case is
+ ``FilterSerializer``) to validate the nested payload. The validated
+ data produced by that serializer is returned so that the enclosing
+ serializer (``FilterSerializer``) can include it in its own
+ ``validated_data`` structure.
+ """
+
+ parent_cls = self.parent.__class__ # FilterSerializer
+ serializer = parent_cls(data=data, context=self.context)
+ serializer.is_valid(raise_exception=True)
+ return serializer.validated_data
+
+
class FilterSerializer(serializers.ModelSerializer):
+ child_filter = ChildFilterSerializer(required=False)
+
class Meta:
model = Filter
fields = '__all__'
@@ -73,6 +101,35 @@ def validate_column(self, column: str) -> str:
class FilterGroupSerializer(serializers.ModelSerializer):
filters = FilterSerializer(many=True)
+ def to_representation(self, instance):
+ def _build_filter_tree(filter_obj):
+ """Build hierarchical filter representation."""
+ item = {
+ 'filter': filter_obj.column,
+ 'operator': filter_obj.operator,
+ 'type': filter_obj.type,
+ 'value': filter_obj.value,
+ }
+
+ # Add child filter if exists (only one level of nesting)
+ child_filters = filter_obj.children.all()
+ if child_filters:
+ child = child_filters[0] # Only support one child
+ child_item = {
+ 'filter': child.column,
+ 'operator': child.operator,
+ 'type': child.type,
+ 'value': child.value,
+ }
+ item['child_filter'] = child_item
+
+ return item
+
+ # Only process root filters (ordered by index)
+ roots = instance.filters.filter(parent__isnull=True).prefetch_related('children').order_by('index')
+
+ return {'conjunction': instance.conjunction, 'items': [_build_filter_tree(f) for f in roots]}
+
class Meta:
model = FilterGroup
fields = '__all__'
@@ -114,15 +171,26 @@ def to_internal_value(self, data):
if 'filter_group' not in data and conjunction:
data['filter_group'] = {'conjunction': conjunction, 'filters': []}
if 'items' in filters:
+ # Support "nested" list where each root item may contain ``child_filters``
+
+ def _convert_filter(src_filter):
+ """Convert a single filter JSON object into internal representation."""
+
+ filter_payload = {
+ 'column': src_filter.get('filter', ''),
+ 'operator': src_filter.get('operator', ''),
+ 'type': src_filter.get('type', ''),
+ 'value': src_filter.get('value', {}),
+ }
+
+ if child_filter := src_filter.get('child_filter'):
+ filter_payload['child_filter'] = _convert_filter(child_filter)
+
+ return filter_payload
+
+ # Iterate over top-level items (roots)
for f in filters['items']:
- data['filter_group']['filters'].append(
- {
- 'column': f.get('filter', ''),
- 'operator': f.get('operator', ''),
- 'type': f.get('type', ''),
- 'value': f.get('value', {}),
- }
- )
+ data['filter_group']['filters'].append(_convert_filter(f))
ordering = _data.pop('ordering', {})
data['ordering'] = ordering
@@ -131,21 +199,10 @@ def to_internal_value(self, data):
def to_representation(self, instance):
result = super().to_representation(instance)
+
+ # Handle filter_group serialization
filters = result.pop('filter_group', {})
if filters:
- filters['items'] = []
- filters.pop('filters', [])
- filters.pop('id', None)
-
- for f in instance.filter_group.filters.order_by('index'):
- filters['items'].append(
- {
- 'filter': f.column,
- 'operator': f.operator,
- 'type': f.type,
- 'value': f.value,
- }
- )
result['data']['filters'] = filters
selected_items = result.pop('selected_items', {})
@@ -159,11 +216,38 @@ def to_representation(self, instance):
@staticmethod
def _create_filters(filter_group, filters_data):
- filter_index = 0
- for filter_data in filters_data:
- filter_data['index'] = filter_index
- filter_group.filters.add(Filter.objects.create(**filter_data))
- filter_index += 1
+ """Create Filter objects inside the provided ``filter_group``.
+
+ * For **root** filters (``parent`` is ``None``) we enumerate the
+ ``index`` so that the UI can preserve left-to-right order.
+ * For **child** filters we leave ``index`` as ``None`` – they are not
+ shown in the top-level ordering bar.
+ """
+
+ def _create_recursive(data, parent=None, index=None):
+
+ # Extract nested children early (if any) and remove them from payload
+ child_filter = data.pop('child_filter', None)
+
+ # Handle explicit parent reference present in the JSON payload only
+ # for root elements. For nested structures we rely on the actual
+ # ``parent`` FK object instead of its primary key.
+ if parent is not None:
+ data.pop('parent', None)
+
+ # Assign display order for root filters
+ if parent is None:
+ data['index'] = index
+
+ # Persist the filter
+ obj = Filter.objects.create(parent=parent, **data)
+ filter_group.filters.add(obj)
+
+ if child_filter:
+ _create_recursive(child_filter, parent=obj)
+
+ for index, data in enumerate(filters_data):
+ _create_recursive(data, index=index)
def create(self, validated_data):
with transaction.atomic():
@@ -190,6 +274,8 @@ def update(self, instance, validated_data):
filter_group = instance.filter_group
if filter_group is None:
filter_group = FilterGroup.objects.create(**filter_group_data)
+ instance.filter_group = filter_group
+ instance.save(update_fields=['filter_group'])
conjunction = filter_group_data.get('conjunction')
if conjunction and filter_group.conjunction != conjunction:
diff --git a/label_studio/tests/data_manager/test_views_api.py b/label_studio/tests/data_manager/test_views_api.py
index 317bc1f59e83..f34b3dca71d5 100644
--- a/label_studio/tests/data_manager/test_views_api.py
+++ b/label_studio/tests/data_manager/test_views_api.py
@@ -190,6 +190,434 @@ def test_views_api_filters(business_client, project_id):
assert response.json()['data'] == updated_payload['data']
+def test_views_api_nested_filters(business_client, project_id):
+ """Test creating views with nested filters using child filters.
+
+ This test validates the nested filter structure where a parent filter
+ can have child filters that are AND-merged with the parent. This is
+ similar to the enterprise annotations_results_json filters but uses
+ regular task data filters.
+ """
+ # Create a project with specific label config for testing
+ project_response = business_client.post(
+ '/api/projects/',
+ data=json.dumps(
+ {
+ 'title': 'test_nested_filters',
+ 'label_config': """
+
+
+
+
+
+
+
+
+
+
+
+ """,
+ }
+ ),
+ content_type='application/json',
+ )
+ assert project_response.status_code == 201
+ project = project_response.json()
+
+ # Create tasks with different data
+ task1_data = {'text': 'task1', 'category': 'A'}
+ task1_response = business_client.post(
+ f'/api/projects/{project["id"]}/tasks',
+ data=json.dumps({'data': task1_data}),
+ content_type='application/json',
+ )
+ assert task1_response.status_code == 201
+ task1 = task1_response.json()
+
+ task2_data = {'text': 'task2', 'category': 'B'}
+ task2_response = business_client.post(
+ f'/api/projects/{project["id"]}/tasks',
+ data=json.dumps({'data': task2_data}),
+ content_type='application/json',
+ )
+ assert task2_response.status_code == 201
+ task2 = task2_response.json()
+
+ task3_data = {'text': 'task3', 'category': 'A'}
+ task3_response = business_client.post(
+ f'/api/projects/{project["id"]}/tasks',
+ data=json.dumps({'data': task3_data}),
+ content_type='application/json',
+ )
+ assert task3_response.status_code == 201
+ task3 = task3_response.json()
+
+ # Create a view with nested filters
+ # Parent filter: tasks with category 'A'
+ # Child filter: tasks with text containing 'task1'
+ nested_filter_payload = {
+ 'project': project['id'],
+ 'data': {
+ 'filters': {
+ 'conjunction': 'and',
+ 'items': [
+ {
+ 'filter': 'filter:tasks:data.category',
+ 'operator': 'equal',
+ 'type': 'String',
+ 'value': 'A',
+ 'child_filter': {
+ 'filter': 'filter:tasks:data.text',
+ 'operator': 'contains',
+ 'type': 'String',
+ 'value': 'task1',
+ },
+ }
+ ],
+ }
+ },
+ }
+
+ response = business_client.post(
+ '/api/dm/views/',
+ data=json.dumps(nested_filter_payload),
+ content_type='application/json',
+ )
+ assert response.status_code == 201, response.content
+ view_id = response.json()['id']
+
+ # Retrieve the created view and verify the nested structure
+ response = business_client.get(f'/api/dm/views/{view_id}/')
+ assert response.status_code == 200, response.content
+
+ view_data = response.json()['data']
+ filter_data = view_data['filters']
+
+ # Verify the filter structure
+ assert filter_data['conjunction'] == 'and'
+ assert len(filter_data['items']) == 1
+
+ root_filter = filter_data['items'][0]
+ assert root_filter['filter'] == 'filter:tasks:data.category'
+ assert root_filter['operator'] == 'equal'
+ assert root_filter['type'] == 'String'
+ assert root_filter['value'] == 'A'
+
+ # Verify child filter structure
+ assert 'child_filter' in root_filter
+ child_filter = root_filter['child_filter']
+ assert child_filter['filter'] == 'filter:tasks:data.text'
+ assert child_filter['operator'] == 'contains'
+ assert child_filter['type'] == 'String'
+ assert child_filter['value'] == 'task1'
+
+ # Test that the view filters tasks correctly
+ # Only task1 should match: category='A' AND text contains 'task1'
+ response = business_client.get(f'/api/tasks?view={view_id}')
+ assert response.status_code == 200, response.content
+
+ tasks = response.json()['tasks']
+ assert len(tasks) == 1
+ assert tasks[0]['id'] == task1['id']
+
+ # Test with a different nested filter structure
+ # Parent filter: tasks with category 'A' or 'B'
+ # Child filter: tasks with text containing 'task'
+ complex_nested_payload = {
+ 'project': project['id'],
+ 'data': {
+ 'filters': {
+ 'conjunction': 'or',
+ 'items': [
+ {
+ 'filter': 'filter:tasks:data.category',
+ 'operator': 'equal',
+ 'type': 'String',
+ 'value': 'A',
+ 'child_filter': {
+ 'filter': 'filter:tasks:data.text',
+ 'operator': 'contains',
+ 'type': 'String',
+ 'value': 'task',
+ },
+ },
+ {
+ 'filter': 'filter:tasks:data.category',
+ 'operator': 'equal',
+ 'type': 'String',
+ 'value': 'B',
+ 'child_filter': {
+ 'filter': 'filter:tasks:data.text',
+ 'operator': 'contains',
+ 'type': 'String',
+ 'value': 'task',
+ },
+ },
+ ],
+ }
+ },
+ }
+
+ response = business_client.post(
+ '/api/dm/views/',
+ data=json.dumps(complex_nested_payload),
+ content_type='application/json',
+ )
+ assert response.status_code == 201, response.content
+ complex_view_id = response.json()['id']
+
+ # Test the complex nested filter
+ response = business_client.get(f'/api/tasks?view={complex_view_id}')
+ assert response.status_code == 200, response.content
+
+ tasks = response.json()['tasks']
+ # Should match all tasks: (category='A' AND text contains 'task') OR (category='B' AND text contains 'task')
+ assert len(tasks) == 3
+ task_ids = [task['id'] for task in tasks]
+ assert task1['id'] in task_ids
+ assert task2['id'] in task_ids
+ assert task3['id'] in task_ids
+
+
+def test_views_api_patch_add_child_filter(business_client, project_id):
+ """Test creating a view with a non-nested filter, then PATCHing it to add a child filter.
+
+ This test validates the behavior of updating a view's filter structure by adding
+ child filters to existing filters through PATCH requests.
+ """
+ # Create a project with specific label config for testing
+ project_response = business_client.post(
+ '/api/projects/',
+ data=json.dumps(
+ {
+ 'title': 'test_patch_child_filter',
+ 'label_config': """
+
+
+
+
+
+
+
+
+
+
+
+ """,
+ }
+ ),
+ content_type='application/json',
+ )
+ assert project_response.status_code == 201
+ project = project_response.json()
+
+ # Create tasks with different data
+ task1_data = {'text': 'task1', 'category': 'A'}
+ task1_response = business_client.post(
+ f'/api/projects/{project["id"]}/tasks',
+ data=json.dumps({'data': task1_data}),
+ content_type='application/json',
+ )
+ assert task1_response.status_code == 201
+ task1 = task1_response.json()
+
+ task2_data = {'text': 'task2', 'category': 'A'}
+ task2_response = business_client.post(
+ f'/api/projects/{project["id"]}/tasks',
+ data=json.dumps({'data': task2_data}),
+ content_type='application/json',
+ )
+ assert task2_response.status_code == 201
+ task2 = task2_response.json()
+
+ task3_data = {'text': 'task3', 'category': 'B'}
+ task3_response = business_client.post(
+ f'/api/projects/{project["id"]}/tasks',
+ data=json.dumps({'data': task3_data}),
+ content_type='application/json',
+ )
+ assert task3_response.status_code == 201
+ task3 = task3_response.json()
+
+ # Step 1: Create a view with a non-nested filter
+ # Filter: tasks with category 'A'
+ simple_filter_payload = {
+ 'project': project['id'],
+ 'data': {
+ 'filters': {
+ 'conjunction': 'and',
+ 'items': [
+ {
+ 'filter': 'filter:tasks:data.category',
+ 'operator': 'equal',
+ 'type': 'String',
+ 'value': 'A',
+ }
+ ],
+ }
+ },
+ }
+
+ response = business_client.post(
+ '/api/dm/views/',
+ data=json.dumps(simple_filter_payload),
+ content_type='application/json',
+ )
+ assert response.status_code == 201, response.content
+ view_id = response.json()['id']
+
+ # Verify the initial view has no child filters
+ response = business_client.get(f'/api/dm/views/{view_id}/')
+ assert response.status_code == 200, response.content
+
+ view_data = response.json()['data']
+ filter_data = view_data['filters']
+
+ # Verify the initial filter structure (no child filters)
+ assert filter_data['conjunction'] == 'and'
+ assert len(filter_data['items']) == 1
+
+ root_filter = filter_data['items'][0]
+ assert root_filter['filter'] == 'filter:tasks:data.category'
+ assert root_filter['operator'] == 'equal'
+ assert root_filter['type'] == 'String'
+ assert root_filter['value'] == 'A'
+
+ # Verify no child filter exists initially
+ assert 'child_filter' not in root_filter
+
+ # Test that the initial view filters tasks correctly
+ # Should match task1 and task2 (both have category='A')
+ response = business_client.get(f'/api/tasks?view={view_id}')
+ assert response.status_code == 200, response.content
+
+ tasks = response.json()['tasks']
+ assert len(tasks) == 2
+ task_ids = [task['id'] for task in tasks]
+ assert task1['id'] in task_ids
+ assert task2['id'] in task_ids
+ assert task3['id'] not in task_ids
+
+ # Step 2: PATCH the view to add a child filter
+ # Add child filter: tasks with text containing 'task1'
+ patch_payload = {
+ 'data': {
+ 'filters': {
+ 'conjunction': 'and',
+ 'items': [
+ {
+ 'filter': 'filter:tasks:data.category',
+ 'operator': 'equal',
+ 'type': 'String',
+ 'value': 'A',
+ 'child_filter': {
+ 'filter': 'filter:tasks:data.text',
+ 'operator': 'contains',
+ 'type': 'String',
+ 'value': 'task1',
+ },
+ }
+ ],
+ }
+ },
+ }
+
+ response = business_client.patch(
+ f'/api/dm/views/{view_id}/',
+ data=json.dumps(patch_payload),
+ content_type='application/json',
+ )
+ assert response.status_code == 200, response.content
+
+ # Step 3: Verify the PATCHed view has the child filter
+ response = business_client.get(f'/api/dm/views/{view_id}/')
+ assert response.status_code == 200, response.content
+
+ view_data = response.json()['data']
+ filter_data = view_data['filters']
+
+ # Verify the updated filter structure (now has child filter)
+ assert filter_data['conjunction'] == 'and'
+ assert len(filter_data['items']) == 1
+
+ root_filter = filter_data['items'][0]
+ assert root_filter['filter'] == 'filter:tasks:data.category'
+ assert root_filter['operator'] == 'equal'
+ assert root_filter['type'] == 'String'
+ assert root_filter['value'] == 'A'
+
+ # Verify child filter was added
+ assert 'child_filter' in root_filter
+ child_filter = root_filter['child_filter']
+ assert child_filter['filter'] == 'filter:tasks:data.text'
+ assert child_filter['operator'] == 'contains'
+ assert child_filter['type'] == 'String'
+ assert child_filter['value'] == 'task1'
+
+ # Step 4: Test that the PATCHed view filters tasks correctly
+ # Should now only match task1: category='A' AND text contains 'task1'
+ response = business_client.get(f'/api/tasks?view={view_id}')
+ assert response.status_code == 200, response.content
+
+ tasks = response.json()['tasks']
+ assert len(tasks) == 1
+ assert tasks[0]['id'] == task1['id']
+
+ # Step 5: PATCH again to modify the child filter
+ # Change child filter to: tasks with text containing 'task'
+ patch_payload_2 = {
+ 'data': {
+ 'filters': {
+ 'conjunction': 'and',
+ 'items': [
+ {
+ 'filter': 'filter:tasks:data.category',
+ 'operator': 'equal',
+ 'type': 'String',
+ 'value': 'A',
+ 'child_filter': {
+ 'filter': 'filter:tasks:data.text',
+ 'operator': 'contains',
+ 'type': 'String',
+ 'value': 'task',
+ },
+ }
+ ],
+ }
+ },
+ }
+
+ response = business_client.patch(
+ f'/api/dm/views/{view_id}/',
+ data=json.dumps(patch_payload_2),
+ content_type='application/json',
+ )
+ assert response.status_code == 200, response.content
+
+ # Step 6: Verify the child filter was updated
+ response = business_client.get(f'/api/dm/views/{view_id}/')
+ assert response.status_code == 200, response.content
+
+ view_data = response.json()['data']
+ filter_data = view_data['filters']
+
+ root_filter = filter_data['items'][0]
+ child_filter = root_filter['child_filter']
+ assert child_filter['value'] == 'task' # Updated value
+
+ # Test that the updated view filters tasks correctly
+ # Should now match task1 and task2: category='A' AND text contains 'task'
+ response = business_client.get(f'/api/tasks?view={view_id}')
+ assert response.status_code == 200, response.content
+
+ tasks = response.json()['tasks']
+ assert len(tasks) == 2
+ task_ids = [task['id'] for task in tasks]
+ assert task1['id'] in task_ids
+ assert task2['id'] in task_ids
+ assert task3['id'] not in task_ids
+
+
def test_views_ordered_by_id(business_client, project_id):
views = [{'view_data': 1}, {'view_data': 2}, {'view_data': 3}]
diff --git a/poetry.lock b/poetry.lock
index 89dad017f50e..18c9d5fd9211 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -2136,7 +2136,7 @@ optional = false
python-versions = ">=3.9,<4"
groups = ["main"]
files = [
- {file = "07ed2fe6387c745e9026e8d650039168721de33e.zip", hash = "sha256:9e47cf8404e86c010e123ed760723717c62be26b6f631e2db873ae81af586fa4"},
+ {file = "8ec49aacf26227c1e5956bfbfec907a52e591931.zip", hash = "sha256:b2938e4c79d7b8c36490cf96e26aa30b792dda70d4e121ac4721d642f814b938"},
]
[package.dependencies]
@@ -2164,7 +2164,7 @@ xmljson = "0.2.1"
[package.source]
type = "url"
-url = "https://github.com/HumanSignal/label-studio-sdk/archive/07ed2fe6387c745e9026e8d650039168721de33e.zip"
+url = "https://github.com/HumanSignal/label-studio-sdk/archive/8ec49aacf26227c1e5956bfbfec907a52e591931.zip"
[[package]]
name = "launchdarkly-server-sdk"
@@ -5037,4 +5037,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<4"
-content-hash = "7d774234df518aac5bfada63276f934cf98588a1a888372305be76bbac883d33"
+content-hash = "cda57c69bcd1815802029e8f6704729f21a693c57623b12e869263aec296894d"
diff --git a/pyproject.toml b/pyproject.toml
index 565ceb938be1..8e25ab759df9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -73,7 +73,7 @@ dependencies = [
"djangorestframework-simplejwt[crypto] (>=5.4.0,<6.0.0)",
"tldextract (>=5.1.3)",
## HumanSignal repo dependencies :start
- "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/07ed2fe6387c745e9026e8d650039168721de33e.zip",
+ "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/8ec49aacf26227c1e5956bfbfec907a52e591931.zip",
## HumanSignal repo dependencies :end
]
diff --git a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx
index 2b2bc9f15272..00685ef154b1 100644
--- a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx
+++ b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
-import { Fragment } from "react";
import { BemWithSpecifiContext } from "../../../utils/bem";
import { Button } from "@humansignal/ui";
import { IconClose } from "@humansignal/icons";
@@ -26,14 +25,14 @@ const Conjunction = observer(({ index, view }) => {
);
});
-const GroupWrapper = ({ children, wrap = false }) => {
- return wrap ? {children} : children;
-};
-
export const FilterLine = observer(({ filter, availableFilters, index, view, sidebar, dropdownClassName }) => {
- return (
-
-
+ const childFilter = filter.child_filter;
+
+ if (sidebar) {
+ // Sidebar layout uses grid structure like main layout
+ return (
+
+ {/* Main filter row */}
{index === 0 ? (
Where
@@ -41,15 +40,13 @@ export const FilterLine = observer(({ filter, availableFilters, index, view, sid
)}
+
{
const original = option?.original ?? option;
const title = original?.field?.title ?? original?.title ?? "";
@@ -70,8 +67,7 @@ export const FilterLine = observer(({ filter, availableFilters, index, view, sid
disabled={filter.field.disabled}
/>
-
-
+
-
-
-
+ );
+ }
+
+ // Main layout uses parent grid structure - render children as direct grid items
+ return (
+
+
+ {index === 0 ? (
+ Where
+ ) : (
+
+ )}
+
+
+
+ {
+ const original = option?.original ?? option;
+ const title = original?.field?.title ?? original?.title ?? "";
+ const parentTitle = original?.field?.parent?.title ?? "";
+ return `${title} ${parentTitle}`.toLowerCase().includes(query.toLowerCase());
}}
+ onChange={(value) => filter.setFilterDelayed(value)}
+ optionRender={({ item: { original: filter } }) => (
+
+ {filter.field.title}
+ {filter.field.parent && (
+
+ {filter.field.parent.title}
+
+ )}
+
+ )}
disabled={filter.field.disabled}
- icon={}
/>
+
+
+
+ {/* Only show remove button if there's no child filter, or show it on the last column of the main filter */}
+ {!childFilter && (
+
+ {
+ e.stopPropagation();
+ filter.delete();
+ }}
+ icon={}
+ />
+
+ )}
+
+ {/* Render child filters as additional grid items on new row */}
+ {childFilter && (
+ <>
+ {/* Empty column to maintain grid alignment for main filter row */}
+
+
+
+ and
+
+
+
+ {}} // No-op since it's disabled
+ />
+
+
+
+
+ {/* Show remove button on child filter row - removes the entire filter group */}
+
+ {
+ e.stopPropagation();
+ filter.delete(); // Remove the main filter (which includes child)
+ }}
+ icon={}
+ />
+
+ >
+ )}
);
});
diff --git a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.scss b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.scss
index ae032e556db3..9e5eda5c35b2 100644
--- a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.scss
+++ b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.scss
@@ -1,19 +1,18 @@
-.filter-line {
+.filterLine {
+ position: relative;
+ width: 100%;
+
+ // Apply the same grid layout as the parent filters container
display: grid;
- min-height: 24px;
- max-width: 550px;
- min-width: min-content;
- padding: 5px 10px;
- box-sizing: content-box;
- align-items: flex-start;
- grid-gap: 2px;
- grid-template-columns: 70px 110px 100px 120px 24px;
+ grid-template-columns: 80px minmax(120px, max-content) minmax(100px, max-content) 1fr 24px;
+ grid-gap: var(--spacing-tightest);
+ align-items: start;
&__remove {
- height: 100%;
display: flex;
align-items: center;
justify-content: center;
+ margin-top: -4px;
.button-dm {
height: 24px;
@@ -30,17 +29,21 @@
display: flex;
align-items: center;
justify-content: flex-end;
- width: 75px;
}
&.operation {
- flex: 0;
+ display: flex;
+ align-items: center;
}
&.value {
- flex: 1;
- display: grid;
- grid-auto-flow: column;
+ display: flex;
+ align-items: center;
+ }
+
+ &.field {
+ display: flex;
+ align-items: center;
}
}
@@ -58,4 +61,8 @@
align-items: center;
justify-content: space-between;
}
+
+ .selectTrigger {
+ padding: 0 4px;
+ }
}
diff --git a/web/libs/datamanager/src/components/Filters/Filters.scss b/web/libs/datamanager/src/components/Filters/Filters.scss
index e9ae0f6314d2..02a273c7a0b7 100644
--- a/web/libs/datamanager/src/components/Filters/Filters.scss
+++ b/web/libs/datamanager/src/components/Filters/Filters.scss
@@ -12,31 +12,6 @@
&_sidebar {
width: 100%;
-
- .filter-line {
- padding-right: var(--spacing-tight);
- padding-left: var(--spacing-tight);
- align-items: stretch;
-
- .ant-divider {
- margin: 0;
- height: 24px;
- }
-
- &__field {
- width: 100%;
- }
-
- &__settings {
- flex-direction: column;
- }
-
- &__group {
- flex: 1;
- padding: 2px 0;
- width: 100%;
- }
- }
}
&__actions {
@@ -64,9 +39,9 @@
padding: var(--spacing-tight) var(--spacing-tight) var(--spacing-tighter);
&_withFilters {
- display: grid;
- grid-template-columns: 75px min-content min-content 1fr min-content;
- grid-gap: 4px;
+ display: flex;
+ flex-flow: column nowrap;
+ gap: var(--spacing-tight);
}
}
@@ -74,10 +49,9 @@
padding: var(--spacing-base) var(--spacing-tight) var(--spacing-tighter);
&_withFilters {
- grid-template-columns: 1fr 1fr 24px;
- grid-row-gap: 4px;
- grid-auto-flow: row;
- align-items: center;
+ display: flex;
+ flex-flow: column nowrap;
+ gap: var(--spacing-tight);
}
}
}
@@ -89,3 +63,5 @@
display: inline-flex;
align-items: center;
}
+
+// Note: Dropdown padding is now controlled via triggerProps in FilterDropdown component
diff --git a/web/libs/datamanager/src/components/Filters/types/String.jsx b/web/libs/datamanager/src/components/Filters/types/String.jsx
index c662d67b29f7..1a51f8351bb0 100644
--- a/web/libs/datamanager/src/components/Filters/types/String.jsx
+++ b/web/libs/datamanager/src/components/Filters/types/String.jsx
@@ -2,9 +2,7 @@ import { observer } from "mobx-react";
import { FilterInput } from "../FilterInput";
const BaseInput = observer(({ value, onChange, placeholder }) => {
- return (
-
- );
+ return ;
});
export const StringFilter = [
diff --git a/web/libs/datamanager/src/stores/Tabs/tab.js b/web/libs/datamanager/src/stores/Tabs/tab.js
index 8e1d4862b836..9a4e0bd34a82 100644
--- a/web/libs/datamanager/src/stores/Tabs/tab.js
+++ b/web/libs/datamanager/src/stores/Tabs/tab.js
@@ -110,10 +110,12 @@ export const Tab = types
},
get currentFilters() {
- if (isFF(FF_ANNOTATION_RESULTS_FILTERING)) {
- return self.filters.filter((f) => f.target === self.target);
- }
- return self.filters.filter((f) => f.target === self.target && !f.field.isAnnotationResultsFilterColumn);
+ return self.filters.filter((f) => {
+ const targetMatches = f.target === self.target;
+ const annotationResultsOK = isFF(FF_ANNOTATION_RESULTS_FILTERING) || !f.field.isAnnotationResultsFilterColumn;
+
+ return targetMatches && annotationResultsOK;
+ });
},
get currentOrder() {
@@ -142,15 +144,26 @@ export const Tab = types
},
get serializedFilters() {
- return self.validFilters.map((el) => {
- const filterItem = {
- ...getSnapshot(el),
- type: el.filter.currentType,
+ const serialize = (filterModel) => {
+ const item = {
+ ...getSnapshot(filterModel),
+ type: filterModel.filter.currentType,
};
- filterItem.value = normalizeFilterValue(filterItem.type, filterItem.operator, filterItem.value);
- return filterItem;
- });
+ // cleanup or recurse on child_filter
+ if (item.child_filter) {
+ if (!filterModel.child_filter?.isValidFilter) {
+ item.child_filter = null;
+ } else {
+ item.child_filter = serialize(filterModel.child_filter);
+ }
+ }
+
+ item.value = normalizeFilterValue(item.type, item.operator, item.value);
+ return item;
+ };
+
+ return self.validFilters.map((el) => serialize(el));
},
get selectedCount() {
@@ -375,9 +388,27 @@ export const Tab = types
self.filters.push(filter);
+ // Immediately materialize child filter for the default column, if any
+ self.applyChildFilter(filter);
+
if (filter.isValidFilter) self.save();
},
+ /**
+ * Create a new filter row for the provided filter *type* (column).
+ */
+ createChildFilterForType(filterType, parentFilter) {
+ const filter = TabFilter.create({
+ filter: filterType,
+ view: self.id,
+ });
+
+ // Don't add to main filters array - child is owned by parent
+ parentFilter.child_filter = filter;
+
+ return filter;
+ },
+
toggleColumn(column) {
if (self.hiddenColumns.hasColumn(column)) {
self.hiddenColumns.remove(column);
@@ -399,11 +430,17 @@ export const Tab = types
}),
deleteFilter(filter) {
- const index = self.filters.findIndex((f) => f === filter);
+ // Recursively delete child filter first
+ if (filter.child_filter) {
+ self.deleteFilter(filter.child_filter);
+ }
- self.filters.splice(index, 1);
- destroy(filter);
- self.save();
+ const index = self.filters.findIndex((f) => f === filter);
+ if (index > -1) {
+ self.filters.splice(index, 1);
+ destroy(filter);
+ self.save();
+ }
},
afterAttach() {
@@ -452,6 +489,37 @@ export const Tab = types
markSaved() {
self.saved = true;
},
+
+ /**
+ * Create child filters for a given root filter according to its column's `child_filter` metadata.
+ */
+ applyChildFilter(rootFilter) {
+ if (!rootFilter || !rootFilter.filter || !rootFilter.filter.field) return;
+
+ const column = rootFilter.field;
+ const childFilter = column?.child_filter;
+
+ if (!childFilter) return;
+
+ // NOTE: using targetColumns instead of columns means that annotation results columns cannot be used in child_filters, but seems fine for now
+ const firstChildColumn = self.targetColumns.find((c) => c.alias === childFilter);
+
+ if (firstChildColumn && !rootFilter.child_filter) {
+ const filterType = self.availableFilters.find((ft) => ft.field.id === firstChildColumn.id);
+
+ if (filterType) {
+ const childFilter = self.createChildFilterForType(filterType, rootFilter);
+ }
+ }
+ },
+
+ /** Remove any child filters previously created */
+ clearChildFilter(rootFilter) {
+ if (rootFilter.child_filter) {
+ self.deleteFilter(rootFilter.child_filter);
+ rootFilter.child_filter = null;
+ }
+ },
}))
.preProcessSnapshot((snapshot) => {
if (snapshot === null) return snapshot;
diff --git a/web/libs/datamanager/src/stores/Tabs/tab_column.jsx b/web/libs/datamanager/src/stores/Tabs/tab_column.jsx
index 0853c0e24c0e..dd2a39dd6e48 100644
--- a/web/libs/datamanager/src/stores/Tabs/tab_column.jsx
+++ b/web/libs/datamanager/src/stores/Tabs/tab_column.jsx
@@ -75,6 +75,8 @@ export const TabColumn = types
target: types.enumeration(["tasks", "annotations"]),
orderable: types.optional(types.boolean, true),
help: types.maybeNull(types.string),
+ // Column alias whose filter should be joined automatically when a filter is created for this column
+ child_filter: types.maybeNull(types.string),
disabled: types.optional(types.boolean, false),
})
.views((self) => ({
diff --git a/web/libs/datamanager/src/stores/Tabs/tab_filter.js b/web/libs/datamanager/src/stores/Tabs/tab_filter.js
index ac7cebd4a150..b1f1984afa20 100644
--- a/web/libs/datamanager/src/stores/Tabs/tab_filter.js
+++ b/web/libs/datamanager/src/stores/Tabs/tab_filter.js
@@ -24,6 +24,8 @@ export const TabFilter = types
filter: types.reference(TabFilterType),
operator: types.maybeNull(Operators),
value: types.maybeNull(FilterValueType),
+
+ child_filter: types.maybeNull(types.late(() => TabFilter)),
})
.views((self) => ({
get field() {
@@ -36,7 +38,23 @@ export const TabFilter = types
/** @returns {import("./tab").View} */
get view() {
- return getParent(getParent(self));
+ // For child filters, we need to traverse up to find the tab
+ let current = self;
+ let parent = null;
+
+ try {
+ while (current) {
+ parent = getParent(current);
+ if (parent && parent.filters && Array.isArray(parent.filters)) {
+ return parent;
+ }
+ current = parent;
+ }
+ } catch {
+ return getParent(getParent(self));
+ }
+
+ return null;
},
get component() {
@@ -106,15 +124,19 @@ export const TabFilter = types
setFilter(value, save = true) {
if (!isDefined(value)) return;
+ self.view.clearChildFilter(self);
+
const previousFilterType = self.filter.currentType;
- const previousFilter = self.filter.id;
+ const previousFilter = self.filter;
self.filter = value;
const typeChanged = previousFilterType !== self.filter.currentType;
- const filterChanged = previousFilter !== self.filter.id;
+ const filterChanged = previousFilter !== self.filter;
if (typeChanged || filterChanged) {
+ self.view.applyChildFilter(self);
+
self.markUnsaved();
}
@@ -200,5 +222,6 @@ export const TabFilter = types
}, 300),
}))
.preProcessSnapshot((sn) => {
+ if (!sn) return sn;
return { ...sn, value: sn.value ?? null };
});
diff --git a/web/libs/datamanager/src/utils/feature-flags.js b/web/libs/datamanager/src/utils/feature-flags.js
index da43c7f30512..90376b0a4626 100644
--- a/web/libs/datamanager/src/utils/feature-flags.js
+++ b/web/libs/datamanager/src/utils/feature-flags.js
@@ -60,6 +60,12 @@ export const FF_AVERAGE_AGREEMENT_SCORE_POPOVER = "fflag_feat_all_leap_2042_aver
*/
export const FF_ANNOTATION_RESULTS_FILTERING = "fflag_root_13_annotation_results_filtering";
+/**
+ * Allow to filter tasks in Data Manager by annotation results and user annotated on the same annotation
+ * @link https://app.launchdarkly.com/projects/default/flags/fflag_root_45_better_user_filter
+ */
+export const FF_BETTER_USER_FILTER = "fflag_root_45_better_user_filter";
+
/**
* Disable global user fetching for large-scale deployments
* @link https://app.launchdarkly.com/projects/default/flags/fflag_all_feat_utc_204_users_performance_improvements_in_dm_for_large_orgs