{
  "name": "QueryBuilder",
  "category": "Forms",
  "subcategory": null,
  "description": "Visual AND/OR predicate-tree builder; serializes to JSON or a LINQ predicate.",
  "thumbnail": "/preview-cards/query-builder.png",
  "hasDocsPage": true,
  "nugetPackage": "Lumeo",
  "files": [
    "UI/QueryBuilder/QueryBuilder.razor",
    "UI/QueryBuilder/QueryBuilderGroup.razor",
    "UI/QueryBuilder/QueryBuilderModel.cs"
  ],
  "dependencies": [
    "button"
  ],
  "packageDependencies": [
    "Blazicons.Lucide"
  ],
  "cssVars": [
    "--color-background",
    "--color-border",
    "--color-card",
    "--color-destructive",
    "--color-foreground",
    "--color-input",
    "--color-muted",
    "--color-muted-foreground",
    "--color-primary",
    "--color-primary-foreground",
    "--color-ring",
    "--radius"
  ],
  "api": {
    "name": "QueryBuilder",
    "category": "Forms",
    "subcategory": null,
    "description": "Visual AND/OR predicate-tree builder; serializes to JSON or a LINQ predicate.",
    "nugetPackage": "Lumeo",
    "files": [
      "UI/QueryBuilder/QueryBuilder.razor",
      "UI/QueryBuilder/QueryBuilderGroup.razor",
      "UI/QueryBuilder/QueryBuilderModel.cs"
    ],
    "namespace": "Lumeo",
    "inheritsFrom": null,
    "implements": [],
    "parameters": [
      {
        "name": "Fields",
        "type": "IReadOnlyList<QueryField>",
        "default": "Array.Empty<QueryField>()",
        "description": "The fields the consumer can build conditions against. Required.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Query",
        "type": "QueryGroup?",
        "default": null,
        "description": "The query tree. Two-way bindable via @bind-Query. When null on first render, an empty root group (And, no rules) is created.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "QueryChanged",
        "type": "EventCallback<QueryGroup>",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ShowJsonPreview",
        "type": "bool",
        "default": null,
        "description": "When true, renders a read-only JSON view of the query below the builder.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "MaxDepth",
        "type": "int",
        "default": "5",
        "description": "Maximum group nesting depth. The \"Add group\" button is disabled at this depth.",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "AllowEmptyGroups",
        "type": "bool",
        "default": "true",
        "description": "When false, \"Remove rule\" / \"Remove group\" are hidden when it would leave a group empty (the root may always be empty).",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "ValueEditorTemplate",
        "type": "RenderFragment<QueryRule>?",
        "default": null,
        "description": "Optional override for how a rule's value editor renders. Receives the rule. When supplied, replaces the built-in per-type editor entirely (the consumer is then responsible for binding to Value and calling NotifyChanged via the cascaded context — see docs).",
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "Class",
        "type": "string?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": false
      },
      {
        "name": "AdditionalAttributes",
        "type": "Dictionary<string, object>?",
        "default": null,
        "description": null,
        "isCascading": false,
        "captureUnmatched": true
      }
    ],
    "events": [
      {
        "name": "QueryChanged",
        "type": "EventCallback<QueryGroup>",
        "description": null
      }
    ],
    "enums": [],
    "records": [
      {
        "name": "QueryBuilderContext",
        "signature": "QueryBuilderContext(\n        IReadOnlyList<QueryField> Fields,\n        int MaxDepth,\n        bool AllowEmptyGroups,\n        RenderFragment<QueryRule>? ValueEditorTemplate,\n        Func<Task> NotifyChanged)",
        "description": "Cascaded state shared with the recursive QueryBuilderGroup renderer."
      }
    ],
    "cssVars": [
      "--color-background",
      "--color-border",
      "--color-card",
      "--color-destructive",
      "--color-foreground",
      "--color-input",
      "--color-muted",
      "--color-muted-foreground",
      "--color-primary",
      "--color-primary-foreground",
      "--color-ring",
      "--radius"
    ],
    "examples": [
      {
        "title": "Basic builder",
        "code": "<QueryBuilder Fields=\"_fields\" @bind-Query=\"_query\" />\n\n@code {\n    private readonly IReadOnlyList<QueryField> _fields = new List<QueryField>\n    {\n        new() { Name = \"name\", Label = \"Name\", Type = QueryFieldType.Text },\n        new() { Name = \"age\", Label = \"Age\", Type = QueryFieldType.Number },\n        new() { Name = \"isActive\", Label = \"Active\", Type = QueryFieldType.Boolean },\n        new() { Name = \"plan\", Label = \"Plan\", Type = QueryFieldType.Select,\n                Options = new[] { (\"free\", \"Free\"), (\"pro\", \"Pro\") } }\n    };\n\n    private QueryGroup _query = QueryGroup.CreateEmpty();\n}"
      },
      {
        "title": "With JSON preview",
        "code": "<QueryBuilder Fields=\"_fields\" @bind-Query=\"_query\" ShowJsonPreview=\"true\" />\n\n@code {\n    // ShowJsonPreview renders the live System.Text.Json shape — handy for\n    // showing the consumer exactly what would be sent to a backend, or for debugging.\n    // Programmatically: var json = QueryBuilder.ToJson(_query);\n    //                   var back = QueryBuilder.FromJson(json);\n}"
      },
      {
        "title": "Pre-populated nested groups",
        "code": "<QueryBuilder Fields=\"_fields\" @bind-Query=\"_query\" MaxDepth=\"3\" />\n\n@code {\n    private QueryGroup _query = new()\n    {\n        Combinator = QueryCombinator.And,\n        Rules =\n        {\n            new QueryRule { Field = \"isActive\", Operator = \"=\", Value = true },\n            new QueryGroup\n            {\n                Combinator = QueryCombinator.Or,\n                Rules =\n                {\n                    new QueryRule { Field = \"plan\", Operator = \"=\", Value = \"pro\" },\n                    new QueryRule { Field = \"plan\", Operator = \"=\", Value = \"enterprise\" }\n                }\n            }\n        }\n    };\n\n    // Run it client-side:\n    // var predicate = QueryBuilder.ToExpression<Customer>(_query);\n    // var matches = customers.Where(predicate.Compile());\n}"
      }
    ],
    "subComponents": {
      "QueryBuilderGroup": {
        "componentName": "QueryBuilderGroup",
        "fileName": "QueryBuilderGroup.razor",
        "namespace": "Lumeo",
        "inheritsFrom": null,
        "implements": [],
        "parameters": [
          {
            "name": "Ctx",
            "type": "QueryBuilder.QueryBuilderContext",
            "default": "default!",
            "description": null,
            "isCascading": true,
            "captureUnmatched": false
          },
          {
            "name": "Group",
            "type": "QueryGroup",
            "default": "default!",
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Depth",
            "type": "int",
            "default": null,
            "description": null,
            "isCascading": false,
            "captureUnmatched": false
          },
          {
            "name": "Parent",
            "type": "QueryGroup?",
            "default": null,
            "description": "The parent group (null for the root) — used to support self-removal.",
            "isCascading": false,
            "captureUnmatched": false
          }
        ],
        "events": [],
        "enums": [],
        "records": [],
        "parseFailed": false,
        "parseError": null
      }
    },
    "parseFailed": false,
    "parseError": null
  },
  "$schema": "https://lumeo.nativ.sh/registry-component-schema.json",
  "key": "query-builder",
  "docsUrl": "https://lumeo.nativ.sh/components/query-builder",
  "sourceUrl": [
    {
      "file": "UI/QueryBuilder/QueryBuilder.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo/UI/QueryBuilder/QueryBuilder.razor"
    },
    {
      "file": "UI/QueryBuilder/QueryBuilderGroup.razor",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo/UI/QueryBuilder/QueryBuilderGroup.razor"
    },
    {
      "file": "UI/QueryBuilder/QueryBuilderModel.cs",
      "url": "https://github.com/Brain2k-0005/Lumeo/tree/master/src/Lumeo/UI/QueryBuilder/QueryBuilderModel.cs"
    }
  ],
  "source": [
    {
      "path": "UI/QueryBuilder/QueryBuilder.razor",
      "content": "@namespace Lumeo\n\n<div class=\"@CssClass\" @attributes=\"AdditionalAttributes\">\n    <CascadingValue Value=\"_context\" IsFixed=\"false\">\n        <QueryBuilderGroup Group=\"EffectiveQuery\" Depth=\"0\" Parent=\"null\" />\n    </CascadingValue>\n\n    @if (ShowJsonPreview)\n    {\n        <div class=\"mt-4 rounded-md border border-border/40 bg-muted/30\">\n            <div class=\"border-b border-border/40 px-3 py-1.5 text-xs font-medium text-muted-foreground\">JSON</div>\n            <pre class=\"overflow-x-auto p-3 text-xs text-foreground\"><code>@QueryBuilderSerialization.ToJson(EffectiveQuery)</code></pre>\n        </div>\n    }\n</div>\n\n@code {\n    /// <summary>The fields the consumer can build conditions against. Required.</summary>\n    [Parameter] public IReadOnlyList<QueryField> Fields { get; set; } = Array.Empty<QueryField>();\n\n    /// <summary>The query tree. Two-way bindable via <c>@bind-Query</c>. When <c>null</c> on\n    /// first render, an empty root group (And, no rules) is created.</summary>\n    [Parameter] public QueryGroup? Query { get; set; }\n    [Parameter] public EventCallback<QueryGroup> QueryChanged { get; set; }\n\n    /// <summary>When <c>true</c>, renders a read-only JSON view of the query below the builder.</summary>\n    [Parameter] public bool ShowJsonPreview { get; set; }\n\n    /// <summary>Maximum group nesting depth. The \"Add group\" button is disabled at this depth.</summary>\n    [Parameter] public int MaxDepth { get; set; } = 5;\n\n    /// <summary>When <c>false</c>, \"Remove rule\" / \"Remove group\" are hidden when it would leave\n    /// a group empty (the root may always be empty).</summary>\n    [Parameter] public bool AllowEmptyGroups { get; set; } = true;\n\n    /// <summary>Optional override for how a rule's value editor renders. Receives the rule.\n    /// When supplied, replaces the built-in per-type editor entirely (the consumer is then\n    /// responsible for binding to <see cref=\"QueryRule.Value\"/> and calling\n    /// <c>NotifyChanged</c> via the cascaded context — see docs).</summary>\n    [Parameter] public RenderFragment<QueryRule>? ValueEditorTemplate { get; set; }\n\n    [Parameter] public string? Class { get; set; }\n    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }\n\n    private QueryBuilderContext _context = default!;\n\n    private QueryGroup EffectiveQuery => Query ??= QueryGroup.CreateEmpty();\n\n    private string CssClass => $\"w-full text-sm {Class}\".Trim();\n\n    protected override void OnInitialized()\n    {\n        Query ??= QueryGroup.CreateEmpty();\n        RebuildContext();\n    }\n\n    protected override void OnParametersSet()\n    {\n        Query ??= QueryGroup.CreateEmpty();\n        RebuildContext();\n    }\n\n    private void RebuildContext()\n    {\n        _context = new QueryBuilderContext(\n            Fields,\n            MaxDepth,\n            AllowEmptyGroups,\n            ValueEditorTemplate,\n            NotifyChanged);\n    }\n\n    private async Task NotifyChanged()\n    {\n        await QueryChanged.InvokeAsync(EffectiveQuery);\n        StateHasChanged();\n    }\n\n    // --- Static helpers (re-exported for convenience) ---\n\n    /// <summary>Serializes a query tree to indented JSON (System.Text.Json, camelCase, string enums).</summary>\n    public static string ToJson(QueryGroup query) => QueryBuilderSerialization.ToJson(query);\n\n    /// <summary>Parses a query tree from JSON produced by <see cref=\"ToJson\"/>. Returns <c>null</c> on failure.</summary>\n    public static QueryGroup? FromJson(string json) => QueryBuilderSerialization.FromJson(json);\n\n    /// <summary>Compiles a query tree to a LINQ predicate. Returns <c>null</c> for an empty tree.</summary>\n    public static System.Linq.Expressions.Expression<Func<T, bool>>? ToExpression<T>(QueryGroup query)\n        => QueryBuilderSerialization.ToExpression<T>(query);\n\n    /// <summary>Cascaded state shared with the recursive <see cref=\"QueryBuilderGroup\"/> renderer.</summary>\n    public sealed record QueryBuilderContext(\n        IReadOnlyList<QueryField> Fields,\n        int MaxDepth,\n        bool AllowEmptyGroups,\n        RenderFragment<QueryRule>? ValueEditorTemplate,\n        Func<Task> NotifyChanged)\n    {\n        public QueryField? FindField(string name) => Fields.FirstOrDefault(f => f.Name == name);\n    }\n}\n"
    },
    {
      "path": "UI/QueryBuilder/QueryBuilderGroup.razor",
      "content": "@namespace Lumeo\n\n@{\n    var isRoot = Depth == 0;\n    var canNest = Depth < Ctx.MaxDepth;\n}\n\n<div class=\"@GroupClass\" role=\"group\" aria-label=\"@(isRoot ? \"Query group\" : \"Nested query group\")\">\n    <div class=\"flex flex-wrap items-center gap-2\">\n        <div class=\"inline-flex items-center rounded-md bg-muted p-0.5\" role=\"radiogroup\" aria-label=\"Combinator\">\n            <button type=\"button\"\n                    role=\"radio\"\n                    aria-checked=\"@((Group.Combinator == QueryCombinator.And).ToString().ToLowerInvariant())\"\n                    class=\"@CombinatorPillClass(Group.Combinator == QueryCombinator.And)\"\n                    @onclick=\"@(() => SetCombinator(QueryCombinator.And))\">\n                AND\n            </button>\n            <button type=\"button\"\n                    role=\"radio\"\n                    aria-checked=\"@((Group.Combinator == QueryCombinator.Or).ToString().ToLowerInvariant())\"\n                    class=\"@CombinatorPillClass(Group.Combinator == QueryCombinator.Or)\"\n                    @onclick=\"@(() => SetCombinator(QueryCombinator.Or))\">\n                OR\n            </button>\n        </div>\n\n        <Button Variant=\"Button.ButtonVariant.Outline\" Size=\"Button.ButtonSize.Sm\"\n                OnClick=\"AddRule\" aria-label=\"Add rule\">\n            <Blazicon Svg=\"Lucide.Plus\" class=\"h-3.5 w-3.5\" />\n            <span>Rule</span>\n        </Button>\n\n        <Button Variant=\"Button.ButtonVariant.Outline\" Size=\"Button.ButtonSize.Sm\"\n                OnClick=\"AddGroup\" Disabled=\"@(!canNest)\" aria-label=\"Add group\">\n            <Blazicon Svg=\"Lucide.FolderPlus\" class=\"h-3.5 w-3.5\" />\n            <span>Group</span>\n        </Button>\n\n        <div class=\"ml-auto\"></div>\n\n        @if (!isRoot && CanRemoveSelf)\n        {\n            <Button Variant=\"Button.ButtonVariant.Ghost\" Size=\"Button.ButtonSize.Icon\"\n                    OnClick=\"RemoveSelf\" Class=\"h-7 w-7 text-muted-foreground hover:text-destructive\"\n                    aria-label=\"Remove group\">\n                <Blazicon Svg=\"Lucide.X\" class=\"h-4 w-4\" />\n            </Button>\n        }\n    </div>\n\n    @if (Group.Rules.Count == 0)\n    {\n        <p class=\"px-1 py-2 text-xs text-muted-foreground\">No conditions yet. Add a rule or a group.</p>\n    }\n    else\n    {\n        <div class=\"space-y-2\">\n            @foreach (var node in Group.Rules.ToList())\n            {\n                @if (node is QueryRule rule)\n                {\n                    <div class=\"flex flex-wrap items-center gap-2 rounded-md border border-border/40 bg-card p-2\">\n                        @* Field picker *@\n                        <select class=\"@SelectClass\" value=\"@rule.Field\" @onchange=\"@(e => OnFieldChanged(rule, e.Value?.ToString()))\" aria-label=\"Field\">\n                            @if (string.IsNullOrEmpty(rule.Field))\n                            {\n                                <option value=\"\" disabled selected>Select field…</option>\n                            }\n                            @foreach (var f in Ctx.Fields)\n                            {\n                                <option value=\"@f.Name\" selected=\"@(f.Name == rule.Field)\">@f.DisplayLabel</option>\n                            }\n                        </select>\n\n                        @{\n                            var field = string.IsNullOrEmpty(rule.Field) ? null : Ctx.FindField(rule.Field);\n                        }\n                        @if (field is not null)\n                        {\n                            @* Operator picker *@\n                            <select class=\"@SelectClass\" value=\"@rule.Operator\" @onchange=\"@(e => OnOperatorChanged(rule, e.Value?.ToString()))\" aria-label=\"Operator\">\n                                @foreach (var op in field.EffectiveOperators)\n                                {\n                                    <option value=\"@op\" selected=\"@(op == rule.Operator)\">@QueryBuilderOperators.LabelFor(op)</option>\n                                }\n                            </select>\n\n                            @* Value editor *@\n                            @if (Ctx.ValueEditorTemplate is not null)\n                            {\n                                @Ctx.ValueEditorTemplate(rule)\n                            }\n                            else if (!QueryBuilderOperators.NoValueOperators.Contains(rule.Operator))\n                            {\n                                @RenderValueEditor(field, rule, isSecond: false)\n                                @if (QueryBuilderOperators.RangeOperators.Contains(rule.Operator))\n                                {\n                                    <span class=\"text-xs text-muted-foreground\">and</span>\n                                    @RenderValueEditor(field, rule, isSecond: true)\n                                }\n                            }\n                        }\n\n                        <div class=\"ml-auto\"></div>\n\n                        @if (CanRemoveChild)\n                        {\n                            <Button Variant=\"Button.ButtonVariant.Ghost\" Size=\"Button.ButtonSize.Icon\"\n                                    OnClick=\"@(() => RemoveNode(rule))\" Class=\"h-7 w-7 text-muted-foreground hover:text-destructive\"\n                                    aria-label=\"Remove rule\">\n                                <Blazicon Svg=\"Lucide.X\" class=\"h-4 w-4\" />\n                            </Button>\n                        }\n                    </div>\n                }\n                else if (node is QueryGroup childGroup)\n                {\n                    <QueryBuilderGroup Group=\"childGroup\" Depth=\"@(Depth + 1)\" Parent=\"Group\" />\n                }\n            }\n        </div>\n    }\n</div>\n\n@code {\n    [CascadingParameter] public QueryBuilder.QueryBuilderContext Ctx { get; set; } = default!;\n\n    [Parameter, EditorRequired] public QueryGroup Group { get; set; } = default!;\n    [Parameter] public int Depth { get; set; }\n\n    /// <summary>The parent group (null for the root) — used to support self-removal.</summary>\n    [Parameter] public QueryGroup? Parent { get; set; }\n\n    private bool AllowEmpty => Ctx.AllowEmptyGroups;\n\n    // Removing a child is allowed when empties are permitted, or when more than one child remains.\n    private bool CanRemoveChild => AllowEmpty || Group.Rules.Count > 1;\n\n    // Removing this (non-root) group is allowed when empties are permitted in the parent,\n    // or when the parent would still have at least one child afterwards.\n    private bool CanRemoveSelf => Parent is not null && (AllowEmpty || Parent.Rules.Count > 1);\n\n    private const string GroupBase = \"rounded-lg p-3 space-y-3\";\n\n    private string GroupClass => Depth == 0\n        ? $\"{GroupBase} border border-border/40 bg-card\"\n        : $\"{GroupBase} border-l-2 border-border/40 bg-muted/30 pl-4\";\n\n    private const string SelectClass =\n        \"h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\";\n\n    private const string InputClass =\n        \"h-8 w-40 rounded-md border border-input bg-background px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-[1.5px] focus-visible:ring-ring\";\n\n    private static string CombinatorPillClass(bool active) => active\n        ? \"inline-flex items-center justify-center rounded-[5px] bg-primary px-2.5 py-1 text-xs font-semibold text-primary-foreground\"\n        : \"inline-flex items-center justify-center rounded-[5px] px-2.5 py-1 text-xs font-medium text-muted-foreground hover:text-foreground\";\n\n    private async Task SetCombinator(QueryCombinator c)\n    {\n        if (Group.Combinator == c) return;\n        Group.Combinator = c;\n        await Ctx.NotifyChanged();\n    }\n\n    private async Task AddRule()\n    {\n        var firstField = Ctx.Fields.FirstOrDefault();\n        var rule = new QueryRule\n        {\n            Field = firstField?.Name ?? \"\",\n            Operator = firstField?.EffectiveOperators.FirstOrDefault() ?? \"\",\n            Value = DefaultValueFor(firstField)\n        };\n        Group.Rules.Add(rule);\n        await Ctx.NotifyChanged();\n    }\n\n    private async Task AddGroup()\n    {\n        if (Depth >= Ctx.MaxDepth) return;\n        Group.Rules.Add(QueryGroup.CreateEmpty());\n        await Ctx.NotifyChanged();\n    }\n\n    private async Task RemoveNode(QueryNode node)\n    {\n        Group.Rules.Remove(node);\n        await Ctx.NotifyChanged();\n    }\n\n    private async Task RemoveSelf()\n    {\n        if (Parent is null) return;\n        Parent.Rules.Remove(Group);\n        await Ctx.NotifyChanged();\n    }\n\n    private async Task OnFieldChanged(QueryRule rule, string? newField)\n    {\n        if (string.IsNullOrEmpty(newField) || rule.Field == newField) return;\n        rule.Field = newField;\n        var field = Ctx.FindField(newField);\n        // Field type changed — reset operator to the field's first, clear value.\n        rule.Operator = field?.EffectiveOperators.FirstOrDefault() ?? \"\";\n        rule.Value = DefaultValueFor(field);\n        rule.Value2 = null;\n        await Ctx.NotifyChanged();\n    }\n\n    private async Task OnOperatorChanged(QueryRule rule, string? newOp)\n    {\n        if (string.IsNullOrEmpty(newOp) || rule.Operator == newOp) return;\n        var wasRange = QueryBuilderOperators.RangeOperators.Contains(rule.Operator);\n        rule.Operator = newOp;\n        if (!QueryBuilderOperators.RangeOperators.Contains(newOp)) rule.Value2 = null;\n        if (QueryBuilderOperators.NoValueOperators.Contains(newOp)) { rule.Value = null; rule.Value2 = null; }\n        await Ctx.NotifyChanged();\n    }\n\n    private async Task SetValue(QueryRule rule, bool isSecond, object? value)\n    {\n        if (isSecond) rule.Value2 = value;\n        else rule.Value = value;\n        await Ctx.NotifyChanged();\n    }\n\n    private static object? DefaultValueFor(QueryField? field) => field?.Type switch\n    {\n        QueryFieldType.Boolean => false,\n        _ => null\n    };\n\n    private static string? AsString(object? v) => v?.ToString();\n\n    private RenderFragment RenderValueEditor(QueryField field, QueryRule rule, bool isSecond) => builder =>\n    {\n        var current = isSecond ? rule.Value2 : rule.Value;\n        switch (field.Type)\n        {\n            case QueryFieldType.Number:\n            {\n                builder.OpenElement(0, \"input\");\n                builder.AddAttribute(1, \"type\", \"number\");\n                builder.AddAttribute(2, \"class\", InputClass + \" w-28\");\n                builder.AddAttribute(3, \"value\", AsString(current));\n                builder.AddAttribute(4, \"aria-label\", isSecond ? \"Value (upper bound)\" : \"Value\");\n                builder.AddAttribute(5, \"onchange\",\n                    Microsoft.AspNetCore.Components.EventCallback.Factory.Create<Microsoft.AspNetCore.Components.ChangeEventArgs>(\n                        this, async e =>\n                        {\n                            var raw = e.Value?.ToString();\n                            object? parsed = double.TryParse(raw, System.Globalization.NumberStyles.Any,\n                                System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : raw;\n                            await SetValue(rule, isSecond, parsed);\n                        }));\n                builder.CloseElement();\n                break;\n            }\n            case QueryFieldType.Date:\n            {\n                builder.OpenElement(0, \"input\");\n                builder.AddAttribute(1, \"type\", \"date\");\n                builder.AddAttribute(2, \"class\", InputClass);\n                builder.AddAttribute(3, \"value\", AsString(current));\n                builder.AddAttribute(4, \"aria-label\", isSecond ? \"Date (upper bound)\" : \"Date\");\n                builder.AddAttribute(5, \"onchange\",\n                    Microsoft.AspNetCore.Components.EventCallback.Factory.Create<Microsoft.AspNetCore.Components.ChangeEventArgs>(\n                        this, async e => await SetValue(rule, isSecond, e.Value?.ToString())));\n                builder.CloseElement();\n                break;\n            }\n            case QueryFieldType.Boolean:\n            {\n                var isChecked = current is bool b && b;\n                builder.OpenElement(0, \"label\");\n                builder.AddAttribute(1, \"class\", \"inline-flex items-center gap-1.5 text-sm text-foreground\");\n                builder.OpenElement(2, \"input\");\n                builder.AddAttribute(3, \"type\", \"checkbox\");\n                builder.AddAttribute(4, \"class\", \"h-4 w-4 rounded border-input accent-primary\");\n                builder.AddAttribute(5, \"checked\", isChecked);\n                builder.AddAttribute(6, \"aria-label\", \"Value\");\n                builder.AddAttribute(7, \"onchange\",\n                    Microsoft.AspNetCore.Components.EventCallback.Factory.Create<Microsoft.AspNetCore.Components.ChangeEventArgs>(\n                        this, async e => await SetValue(rule, isSecond, e.Value is bool bb && bb)));\n                builder.CloseElement();\n                builder.AddContent(8, isChecked ? \"true\" : \"false\");\n                builder.CloseElement();\n                break;\n            }\n            case QueryFieldType.Select:\n            {\n                builder.OpenElement(0, \"select\");\n                builder.AddAttribute(1, \"class\", SelectClass);\n                builder.AddAttribute(2, \"value\", AsString(current));\n                builder.AddAttribute(3, \"aria-label\", \"Value\");\n                builder.AddAttribute(4, \"onchange\",\n                    Microsoft.AspNetCore.Components.EventCallback.Factory.Create<Microsoft.AspNetCore.Components.ChangeEventArgs>(\n                        this, async e => await SetValue(rule, isSecond, e.Value?.ToString())));\n                if (string.IsNullOrEmpty(AsString(current)))\n                {\n                    builder.OpenElement(5, \"option\");\n                    builder.AddAttribute(6, \"value\", \"\");\n                    builder.AddAttribute(7, \"disabled\", true);\n                    builder.AddAttribute(8, \"selected\", true);\n                    builder.AddContent(9, \"Select…\");\n                    builder.CloseElement();\n                }\n                var idx = 10;\n                foreach (var (val, label) in field.Options ?? Array.Empty<(string, string)>())\n                {\n                    builder.OpenElement(idx++, \"option\");\n                    builder.AddAttribute(idx++, \"value\", val);\n                    builder.AddAttribute(idx++, \"selected\", val == AsString(current));\n                    builder.AddContent(idx++, label);\n                    builder.CloseElement();\n                }\n                builder.CloseElement();\n                break;\n            }\n            default: // Text\n            {\n                builder.OpenElement(0, \"input\");\n                builder.AddAttribute(1, \"type\", \"text\");\n                builder.AddAttribute(2, \"class\", InputClass);\n                builder.AddAttribute(3, \"value\", AsString(current));\n                builder.AddAttribute(4, \"placeholder\", \"Value\");\n                builder.AddAttribute(5, \"aria-label\", \"Value\");\n                builder.AddAttribute(6, \"onchange\",\n                    Microsoft.AspNetCore.Components.EventCallback.Factory.Create<Microsoft.AspNetCore.Components.ChangeEventArgs>(\n                        this, async e => await SetValue(rule, isSecond, e.Value?.ToString())));\n                builder.CloseElement();\n                break;\n            }\n        }\n    };\n}\n"
    },
    {
      "path": "UI/QueryBuilder/QueryBuilderModel.cs",
      "content": "using System.Linq.Expressions;\nusing System.Text.Json;\nusing System.Text.Json.Serialization;\n\nnamespace Lumeo;\n\n/// <summary>Boolean combinator for a <see cref=\"QueryGroup\"/>.</summary>\npublic enum QueryCombinator\n{\n    And,\n    Or\n}\n\n/// <summary>The data type of a <see cref=\"QueryField\"/>. Drives the default operator\n/// set and the built-in value editor rendered for a rule.</summary>\npublic enum QueryFieldType\n{\n    Text,\n    Number,\n    Date,\n    Boolean,\n    Select\n}\n\n/// <summary>\n/// A field definition the consumer provides to <see cref=\"QueryBuilder\"/>.\n/// <see cref=\"Name\"/> is the stable key written into produced rules;\n/// <see cref=\"Label\"/> is the human-facing text shown in the field picker.\n/// </summary>\npublic sealed class QueryField\n{\n    /// <summary>Stable key written to <see cref=\"QueryRule.Field\"/>. Required.</summary>\n    public string Name { get; set; } = \"\";\n\n    /// <summary>Human-facing label shown in the field picker. Falls back to <see cref=\"Name\"/>.</summary>\n    public string? Label { get; set; }\n\n    /// <summary>The field's data type. Determines the default operator set and value editor.</summary>\n    public QueryFieldType Type { get; set; } = QueryFieldType.Text;\n\n    /// <summary>The operator keys allowed for this field. When <c>null</c>, the default\n    /// set for <see cref=\"Type\"/> is used (see <see cref=\"QueryBuilderOperators.DefaultsFor\"/>).</summary>\n    public IReadOnlyList<string>? Operators { get; set; }\n\n    /// <summary>Options shown when <see cref=\"Type\"/> is <see cref=\"QueryFieldType.Select\"/>.\n    /// Each tuple is <c>(Value, Label)</c>.</summary>\n    public IReadOnlyList<(string Value, string Label)>? Options { get; set; }\n\n    /// <summary>The display label — <see cref=\"Label\"/> when set, otherwise <see cref=\"Name\"/>.</summary>\n    [JsonIgnore]\n    public string DisplayLabel => string.IsNullOrEmpty(Label) ? Name : Label;\n\n    /// <summary>The effective operator keys for this field.</summary>\n    public IReadOnlyList<string> EffectiveOperators =>\n        Operators is { Count: > 0 } ? Operators : QueryBuilderOperators.DefaultsFor(Type);\n}\n\n/// <summary>Base type for a node in a query tree — either a <see cref=\"QueryRule\"/>\n/// (a leaf condition) or a <see cref=\"QueryGroup\"/> (a combinator with children).</summary>\n[JsonDerivedType(typeof(QueryRule), \"rule\")]\n[JsonDerivedType(typeof(QueryGroup), \"group\")]\npublic abstract class QueryNode\n{\n}\n\n/// <summary>A single leaf condition: a field, an operator, and a comparison value.</summary>\npublic sealed class QueryRule : QueryNode\n{\n    /// <summary>The <see cref=\"QueryField.Name\"/> this rule targets.</summary>\n    public string Field { get; set; } = \"\";\n\n    /// <summary>One of the field's allowed operator keys.</summary>\n    public string Operator { get; set; } = \"\";\n\n    /// <summary>The comparison value. Type depends on the field; for \"between\"-style\n    /// operators this holds the lower bound and <see cref=\"Value2\"/> holds the upper.</summary>\n    public object? Value { get; set; }\n\n    /// <summary>The second comparison value, used only by range operators (\"between\").</summary>\n    public object? Value2 { get; set; }\n}\n\n/// <summary>A group: a <see cref=\"QueryCombinator\"/> applied across a list of child\n/// <see cref=\"QueryNode\"/>s (rules and/or nested groups).</summary>\npublic sealed class QueryGroup : QueryNode\n{\n    public QueryCombinator Combinator { get; set; } = QueryCombinator.And;\n\n    public List<QueryNode> Rules { get; set; } = new();\n\n    /// <summary>Creates an empty root group (And, no rules).</summary>\n    public static QueryGroup CreateEmpty() => new() { Combinator = QueryCombinator.And, Rules = new() };\n}\n\n/// <summary>Default operator sets per <see cref=\"QueryFieldType\"/> and friendly display labels.</summary>\npublic static class QueryBuilderOperators\n{\n    public static readonly IReadOnlyList<string> TextDefaults =\n        new[] { \"=\", \"!=\", \"contains\", \"notContains\", \"startsWith\", \"endsWith\", \"isEmpty\", \"isNotEmpty\" };\n\n    public static readonly IReadOnlyList<string> NumberDefaults =\n        new[] { \"=\", \"!=\", \"<\", \"<=\", \">\", \">=\", \"between\", \"isNull\", \"isNotNull\" };\n\n    public static readonly IReadOnlyList<string> DateDefaults =\n        new[] { \"=\", \"!=\", \"<\", \"<=\", \">\", \">=\", \"between\", \"isNull\", \"isNotNull\" };\n\n    public static readonly IReadOnlyList<string> BooleanDefaults =\n        new[] { \"=\", \"!=\" };\n\n    public static readonly IReadOnlyList<string> SelectDefaults =\n        new[] { \"=\", \"!=\", \"in\", \"notIn\", \"isNull\", \"isNotNull\" };\n\n    /// <summary>Friendly display labels for operator keys.</summary>\n    public static readonly IReadOnlyDictionary<string, string> Labels = new Dictionary<string, string>\n    {\n        [\"=\"] = \"equals\",\n        [\"!=\"] = \"not equals\",\n        [\"<\"] = \"less than\",\n        [\"<=\"] = \"less than or equal\",\n        [\">\"] = \"greater than\",\n        [\">=\"] = \"greater than or equal\",\n        [\"contains\"] = \"contains\",\n        [\"notContains\"] = \"does not contain\",\n        [\"startsWith\"] = \"starts with\",\n        [\"endsWith\"] = \"ends with\",\n        [\"between\"] = \"between\",\n        [\"in\"] = \"in\",\n        [\"notIn\"] = \"not in\",\n        [\"isEmpty\"] = \"is empty\",\n        [\"isNotEmpty\"] = \"is not empty\",\n        [\"isNull\"] = \"is null\",\n        [\"isNotNull\"] = \"is not null\"\n    };\n\n    /// <summary>Operators that take no value editor.</summary>\n    public static readonly IReadOnlySet<string> NoValueOperators =\n        new HashSet<string> { \"isEmpty\", \"isNotEmpty\", \"isNull\", \"isNotNull\" };\n\n    /// <summary>Operators that take a second value editor (a range).</summary>\n    public static readonly IReadOnlySet<string> RangeOperators = new HashSet<string> { \"between\" };\n\n    public static IReadOnlyList<string> DefaultsFor(QueryFieldType type) => type switch\n    {\n        QueryFieldType.Text => TextDefaults,\n        QueryFieldType.Number => NumberDefaults,\n        QueryFieldType.Date => DateDefaults,\n        QueryFieldType.Boolean => BooleanDefaults,\n        QueryFieldType.Select => SelectDefaults,\n        _ => TextDefaults\n    };\n\n    public static string LabelFor(string op) => Labels.TryGetValue(op, out var l) ? l : op;\n}\n\n/// <summary>Serialization + LINQ helpers for <see cref=\"QueryGroup\"/> trees.</summary>\npublic static class QueryBuilderSerialization\n{\n    private static readonly JsonSerializerOptions JsonOptions = new()\n    {\n        WriteIndented = true,\n        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,\n        Converters = { new JsonStringEnumConverter() }\n    };\n\n    private static readonly JsonSerializerOptions ReadOptions = new()\n    {\n        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,\n        PropertyNameCaseInsensitive = true,\n        Converters = { new JsonStringEnumConverter() }\n    };\n\n    public static string ToJson(QueryGroup query) =>\n        JsonSerializer.Serialize(query, JsonOptions);\n\n    public static QueryGroup? FromJson(string json)\n    {\n        if (string.IsNullOrWhiteSpace(json)) return null;\n        try\n        {\n            return JsonSerializer.Deserialize<QueryGroup>(json, ReadOptions);\n        }\n        catch (JsonException)\n        {\n            return null;\n        }\n    }\n\n    /// <summary>\n    /// Compiles a query tree to a LINQ predicate <c>Expression&lt;Func&lt;T,bool&gt;&gt;</c>.\n    /// Returns <c>null</c> when the tree is empty (no rules anywhere). Unknown operators or\n    /// missing properties throw <see cref=\"InvalidOperationException\"/>. String comparisons\n    /// for <c>contains</c>/<c>startsWith</c>/<c>endsWith</c> are case-insensitive (ordinal).\n    /// </summary>\n    public static Expression<Func<T, bool>>? ToExpression<T>(QueryGroup query)\n    {\n        var param = Expression.Parameter(typeof(T), \"x\");\n        var body = BuildGroup<T>(query, param);\n        return body is null ? null : Expression.Lambda<Func<T, bool>>(body, param);\n    }\n\n    private static Expression? BuildGroup<T>(QueryGroup group, ParameterExpression param)\n    {\n        var parts = new List<Expression>();\n        foreach (var node in group.Rules)\n        {\n            Expression? part = node switch\n            {\n                QueryGroup g => BuildGroup<T>(g, param),\n                QueryRule r => BuildRule<T>(r, param),\n                _ => null\n            };\n            if (part is not null) parts.Add(part);\n        }\n\n        if (parts.Count == 0) return null;\n\n        var acc = parts[0];\n        for (var i = 1; i < parts.Count; i++)\n        {\n            acc = group.Combinator == QueryCombinator.And\n                ? Expression.AndAlso(acc, parts[i])\n                : Expression.OrElse(acc, parts[i]);\n        }\n        return acc;\n    }\n\n    private static Expression? BuildRule<T>(QueryRule rule, ParameterExpression param)\n    {\n        if (string.IsNullOrEmpty(rule.Field) || string.IsNullOrEmpty(rule.Operator)) return null;\n\n        var prop = typeof(T).GetProperty(rule.Field,\n            System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase);\n        if (prop is null)\n            throw new InvalidOperationException($\"Type '{typeof(T).Name}' has no public property '{rule.Field}'.\");\n\n        var member = Expression.Property(param, prop);\n        var memberType = prop.PropertyType;\n        var underlying = Nullable.GetUnderlyingType(memberType) ?? memberType;\n\n        switch (rule.Operator)\n        {\n            case \"isNull\":\n                return memberType.IsValueType && Nullable.GetUnderlyingType(memberType) is null\n                    ? Expression.Constant(false)\n                    : Expression.Equal(member, Expression.Constant(null, memberType));\n            case \"isNotNull\":\n                return memberType.IsValueType && Nullable.GetUnderlyingType(memberType) is null\n                    ? Expression.Constant(true)\n                    : Expression.NotEqual(member, Expression.Constant(null, memberType));\n            case \"isEmpty\":\n                return Expression.OrElse(\n                    Expression.Equal(member, Expression.Constant(null, memberType)),\n                    Expression.Equal(EnsureString(member), Expression.Constant(\"\")));\n            case \"isNotEmpty\":\n                return Expression.AndAlso(\n                    Expression.NotEqual(member, Expression.Constant(null, memberType)),\n                    Expression.NotEqual(EnsureString(member), Expression.Constant(\"\")));\n        }\n\n        if (rule.Operator is \"contains\" or \"notContains\" or \"startsWith\" or \"endsWith\")\n        {\n            var methodName = rule.Operator switch\n            {\n                \"startsWith\" => nameof(string.StartsWith),\n                \"endsWith\" => nameof(string.EndsWith),\n                _ => nameof(string.Contains)\n            };\n            var method = typeof(string).GetMethod(methodName, new[] { typeof(string), typeof(StringComparison) })!;\n            var needle = Expression.Constant(rule.Value?.ToString() ?? \"\");\n            var call = Expression.Call(EnsureString(member), method, needle, Expression.Constant(StringComparison.OrdinalIgnoreCase));\n            var notNull = Expression.NotEqual(EnsureString(member), Expression.Constant(null, typeof(string)));\n            var guarded = Expression.AndAlso(notNull, call);\n            return rule.Operator == \"notContains\" ? Expression.Not(guarded) : guarded;\n        }\n\n        if (rule.Operator == \"between\")\n        {\n            var lo = Expression.Constant(ConvertValue(rule.Value, underlying), memberType);\n            var hi = Expression.Constant(ConvertValue(rule.Value2, underlying), memberType);\n            return Expression.AndAlso(\n                Expression.GreaterThanOrEqual(member, lo),\n                Expression.LessThanOrEqual(member, hi));\n        }\n\n        if (rule.Operator is \"in\" or \"notIn\")\n        {\n            var values = (rule.Value?.ToString() ?? \"\")\n                .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n            Expression? acc = null;\n            foreach (var v in values)\n            {\n                var eq = Expression.Equal(member, Expression.Constant(ConvertValue(v, underlying), memberType));\n                acc = acc is null ? eq : Expression.OrElse(acc, eq);\n            }\n            acc ??= Expression.Constant(false);\n            return rule.Operator == \"notIn\" ? Expression.Not(acc) : acc;\n        }\n\n        var constant = Expression.Constant(ConvertValue(rule.Value, underlying), memberType);\n        return rule.Operator switch\n        {\n            \"=\" => Expression.Equal(member, constant),\n            \"!=\" => Expression.NotEqual(member, constant),\n            \"<\" => Expression.LessThan(member, constant),\n            \"<=\" => Expression.LessThanOrEqual(member, constant),\n            \">\" => Expression.GreaterThan(member, constant),\n            \">=\" => Expression.GreaterThanOrEqual(member, constant),\n            _ => throw new InvalidOperationException($\"Unsupported operator '{rule.Operator}'.\")\n        };\n    }\n\n    private static Expression EnsureString(Expression member) =>\n        member.Type == typeof(string) ? member : Expression.Call(member, nameof(object.ToString), Type.EmptyTypes);\n\n    private static object? ConvertValue(object? value, Type targetType)\n    {\n        if (value is null) return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;\n        if (targetType.IsInstanceOfType(value)) return value;\n\n        var s = value as string ?? value.ToString();\n        if (string.IsNullOrEmpty(s)) return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;\n\n        if (targetType.IsEnum) return Enum.Parse(targetType, s, ignoreCase: true);\n        if (targetType == typeof(DateOnly)) return DateOnly.Parse(s, System.Globalization.CultureInfo.InvariantCulture);\n        if (targetType == typeof(DateTime)) return DateTime.Parse(s, System.Globalization.CultureInfo.InvariantCulture);\n        if (targetType == typeof(Guid)) return Guid.Parse(s);\n        return Convert.ChangeType(s, targetType, System.Globalization.CultureInfo.InvariantCulture);\n    }\n}\n"
    }
  ],
  "slots": [
    {
      "name": "ValueEditorTemplate",
      "owner": "QueryBuilder",
      "type": "RenderFragment<QueryRule>?",
      "description": "Optional override for how a rule's value editor renders. Receives the rule. When supplied, replaces the built-in per-type editor entirely (the consumer is then responsible for binding to Value and calling NotifyChanged via the cascaded context — see docs)."
    }
  ],
  "serviceDependencies": [],
  "cascadingDependencies": [
    {
      "name": "Ctx",
      "type": "QueryBuilder.QueryBuilderContext",
      "required": true
    }
  ],
  "relatedComponents": [
    {
      "name": "QueryBuilderGroup",
      "reason": "sub-component"
    },
    {
      "name": "Button",
      "reason": "used internally"
    }
  ],
  "keyboardInteractions": [],
  "tests": [
    "tests/Lumeo.Tests/Components/QueryBuilder/QueryBuilderTests.cs"
  ],
  "mdSummary": "# QueryBuilder\n\nVisual AND/OR predicate-tree builder; serializes to JSON or a LINQ predicate.\n\n**Package:** `Lumeo` · **Category:** Forms\n**Docs:** https://lumeo.nativ.sh/components/query-builder\n\n## Parameters\n| Owner | Name | Type | Default | Description |\n|---|---|---|---|---|\n| QueryBuilder | Fields | `IReadOnlyList<QueryField>` | `Array.Empty<QueryField>()` | The fields the consumer can build conditions against. Required. |\n| QueryBuilder | Query | `QueryGroup?` | `` | The query tree. Two-way bindable via @bind-Query. When null on first render, an empty root group (And, no rules) is created. |\n| QueryBuilder | QueryChanged | `EventCallback<QueryGroup>` | `` |  |\n| QueryBuilder | ShowJsonPreview | `bool` | `` | When true, renders a read-only JSON view of the query below the builder. |\n| QueryBuilder | MaxDepth | `int` | `5` | Maximum group nesting depth. The \"Add group\" button is disabled at this depth. |\n| QueryBuilder | AllowEmptyGroups | `bool` | `true` | When false, \"Remove rule\" / \"Remove group\" are hidden when it would leave a group empty (the root may always be empty). |\n| QueryBuilder | ValueEditorTemplate | `RenderFragment<QueryRule>?` | `` | Optional override for how a rule's value editor renders. Receives the rule. When supplied, replaces the built-in per-type editor entirely (the consumer is then responsible for binding to Value and calling NotifyChanged via the cascaded context — see docs). |\n| QueryBuilder | Class | `string?` | `` |  |\n| QueryBuilder | AdditionalAttributes | `Dictionary<string, object>?` | `` |  |\n| QueryBuilderGroup | Ctx | `QueryBuilder.QueryBuilderContext` | `default!` |  |\n| QueryBuilderGroup | Group | `QueryGroup` | `default!` |  |\n| QueryBuilderGroup | Depth | `int` | `` |  |\n| QueryBuilderGroup | Parent | `QueryGroup?` | `` | The parent group (null for the root) — used to support self-removal. |\n\n## Slots\n- `ValueEditorTemplate` (QueryBuilder) — Optional override for how a rule's value editor renders. Receives the rule. When supplied, replaces the built-in per-type editor entirely (the consumer is then responsible for binding to Value and calling NotifyChanged via the cascaded context — see docs).\n\n## Events\n- `QueryChanged` (`EventCallback<QueryGroup>`)\n\n## Sub-components\n- `QueryBuilderGroup`\n\n## Cascading values it expects\n- `Ctx` (`QueryBuilder.QueryBuilderContext`)\n\n## Services it injects\n- (none)\n\n## Keyboard\n—\n\n## Example\n```razor\n<QueryBuilder />\n```\n\n## CSS variables\n`--color-background`, `--color-border`, `--color-card`, `--color-destructive`, `--color-foreground`, `--color-input`, `--color-muted`, `--color-muted-foreground`, `--color-primary`, `--color-primary-foreground`, `--color-ring`, `--radius`\n\n## Files\n- `UI/QueryBuilder/QueryBuilder.razor`\n- `UI/QueryBuilder/QueryBuilderGroup.razor`\n- `UI/QueryBuilder/QueryBuilderModel.cs`\n"
}