diff --git a/frontend/src/components/Filters.vue b/frontend/src/components/Filters.vue
index dacbf5467..1af7c6e6b 100644
--- a/frontend/src/components/Filters.vue
+++ b/frontend/src/components/Filters.vue
@@ -21,14 +21,35 @@
+
+
+
+
2 ? rawFilter.slice(1) : rawFilter[1]
+ }
+ if (isMultiValueOperator(operator) && !Array.isArray(value)) {
+ value = splitCommaValues(String(value ?? ""))
+ }
return {
fieldname,
operator,
@@ -169,11 +203,42 @@ function makeFiltersDict(filtersList: Filter[]) {
if (!filtersList.length) return {}
return filtersList.reduce((acc: Record, filter) => {
const { fieldname, operator, value } = filter
- acc[fieldname] = [operator, value]
+ // in / not in always serialize a nested list — a comma string typed into the
+ // free-text input splits here (the toWireValue convention from @framework/ui).
+ acc[fieldname] = [operator, isMultiValueOperator(operator) ? toValueList(value) : value]
return acc
}, {})
}
+function isMultiValueOperator(operator: Operators) {
+ return operator === "in" || operator === "not in"
+}
+
+function toValueList(value: Filter["value"]): string[] {
+ if (Array.isArray(value)) return value
+ return splitCommaValues(String(value ?? ""))
+}
+
+function splitCommaValues(text: string): string[] {
+ return text
+ .split(",")
+ .map((v) => v.trim())
+ .filter(Boolean)
+}
+
+function setOperator(filter: Filter, operator: Operators) {
+ const wasMulti = isMultiValueOperator(filter.operator)
+ const isMulti = isMultiValueOperator(operator)
+ filter.operator = operator
+ // Keep the value's shape in step with the operator so the inputs never see
+ // the wrong type: scalar → single-element list, list → its first value.
+ if (isMulti && !wasMulti) {
+ filter.value = filter.value ? [String(filter.value)] : []
+ } else if (!isMulti && wasMulti) {
+ filter.value = Array.isArray(filter.value) ? (filter.value[0] ?? "") : filter.value
+ }
+}
+
function getOperators(fieldtype: Fieldtype) {
let options = []
if (typeString.includes(fieldtype) || typeLink.includes(fieldtype)) {
@@ -183,6 +248,8 @@ function getOperators(fieldtype: Fieldtype) {
{ label: "Not Equals", value: "!=" },
{ label: "Like", value: "like" },
{ label: "Not Like", value: "not like" },
+ { label: "In", value: "in" },
+ { label: "Not In", value: "not in" },
],
)
}
@@ -195,6 +262,8 @@ function getOperators(fieldtype: Fieldtype) {
{ label: ">=", value: ">=" },
{ label: "Equals", value: "=" },
{ label: "Not Equals", value: "!=" },
+ { label: "In", value: "in" },
+ { label: "Not In", value: "not in" },
],
)
}
@@ -203,6 +272,8 @@ function getOperators(fieldtype: Fieldtype) {
...[
{ label: "Equals", value: "=" },
{ label: "Not Equals", value: "!=" },
+ { label: "In", value: "in" },
+ { label: "Not In", value: "not in" },
],
)
}
diff --git a/frontend/src/components/MultiLinkInput.vue b/frontend/src/components/MultiLinkInput.vue
new file mode 100644
index 000000000..e914a55df
--- /dev/null
+++ b/frontend/src/components/MultiLinkInput.vue
@@ -0,0 +1,95 @@
+
+
+
+ emit('update:modelValue', v)"
+ @update:query="onQuery"
+ @update:open="onOpen"
+ />
+
+
+
diff --git a/frontend/src/components/MultiSelectInput.vue b/frontend/src/components/MultiSelectInput.vue
new file mode 100644
index 000000000..1adb20f5c
--- /dev/null
+++ b/frontend/src/components/MultiSelectInput.vue
@@ -0,0 +1,45 @@
+
+
+
+ emit('update:modelValue', v)"
+ />
+
+
+
diff --git a/frontend/src/stores/codeStore.ts b/frontend/src/stores/codeStore.ts
index 09345615a..a82b5b8a3 100644
--- a/frontend/src/stores/codeStore.ts
+++ b/frontend/src/stores/codeStore.ts
@@ -218,19 +218,35 @@ const useCodeStore = defineStore("codeStore", () => {
const evaluatedFilters: Filters = {}
for (const key in filters) {
- let value = Array.isArray(filters[key]) ? filters[key][1] : filters[key]
-
- if (isDynamicValue(value)) {
- // null ?? undefined → undefined, so nullish filters get dropped on serialization
- evaluatedFilters[key] = getDynamicValue(value, {}) ?? undefined
+ const raw = filters[key]
+ if (Array.isArray(raw)) {
+ // A list filter is [operator, value] and Frappe unpacks exactly that pair —
+ // the operator must survive to the wire (stripping it turned "!=" and
+ // "not in" filters into equality/bare lists). A flat [op, v1, v2, ...] is
+ // a malformed multi-value filter from older saves — recover it.
+ const operator = raw[0]
+ const value = raw.length > 2 ? raw.slice(1) : raw[1]
+ const evaluated = evaluateFilterValue(value)
+ evaluatedFilters[key] = evaluated === undefined ? undefined : [operator, evaluated]
} else {
- evaluatedFilters[key] = value
+ evaluatedFilters[key] = evaluateFilterValue(raw)
}
}
return evaluatedFilters
}
+ const evaluateFilterValue = (value: any): any => {
+ if (Array.isArray(value)) {
+ return value.map((item) => evaluateFilterValue(item)).filter((item) => item !== undefined)
+ }
+ if (isDynamicValue(value)) {
+ // null ?? undefined → undefined, so nullish filters get dropped on serialization
+ return getDynamicValue(value, {}) ?? undefined
+ }
+ return value
+ }
+
function getAPIParams(params: Record | string | null = null) {
if (!params) return null
if (typeof params === "string") {
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index d3053e729..6250a332d 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -158,7 +158,8 @@ export type Operators =
export type Filter = {
fieldname: string
operator: Operators
- value?: string | null
+ // in / not in carry a list of values; everything else a scalar
+ value?: string | string[] | number | null
field: DocTypeField
}
diff --git a/studio/ai/agent/tools/data.py b/studio/ai/agent/tools/data.py
index 55d710010..b43df4716 100644
--- a/studio/ai/agent/tools/data.py
+++ b/studio/ai/agent/tools/data.py
@@ -19,6 +19,48 @@
RESOURCE_TYPES = ("Document List", "Document", "API Resource")
+# Operators Frappe's query engine accepts as the first element of a [operator, value]
+# filter. A list whose first element isn't one of these is a bare value-list the model
+# meant as "in" — it would crash every fetch with KeyError at runtime, so refuse it here.
+FILTER_OPERATORS = {
+ "=", "!=", "<", ">", "<=", ">=",
+ "like", "not like", "in", "not in", "is", "is not", "not is",
+ "between", "timespan", "previous", "next",
+ "descendants of", "not descendants of", "ancestors of", "not ancestors of",
+} # fmt: skip
+
+
+MULTI_VALUE_OPERATORS = {"in", "not in"}
+
+
+def invalid_filter_message(filters) -> str | None:
+ """Reject filter shapes that would crash at fetch time (Frappe unpacks a list
+ filter as exactly `operator, value = value`), repairing the one unambiguous slip
+ IN PLACE: a flat ["in", "A", "B"] can only mean ["in", ["A", "B"]]."""
+ if not isinstance(filters, dict):
+ return None
+ for field, value in filters.items():
+ if not isinstance(value, list | tuple):
+ continue
+ operator = str(value[0]).casefold() if value else ""
+ if operator not in FILTER_OPERATORS:
+ return (
+ f"FAILED: filter for '{field}' is a bare list {list(value)} — Frappe reads a list as "
+ f"[operator, value], so this crashes at fetch time. For multiple values use "
+ f'{{"{field}": ["in", {list(value)}]}}; for one value pass it directly or with an '
+ f'explicit operator like ["!=", "Closed"].'
+ )
+ if len(value) > 2:
+ if operator in MULTI_VALUE_OPERATORS:
+ filters[field] = [value[0], list(value[1:])]
+ continue
+ return (
+ f"FAILED: filter for '{field}' has {len(value)} elements {list(value)} — a list filter "
+ f'is exactly [operator, value]. Pass ["{value[0]}", ], or use "in"/"not in" '
+ f'with a nested list: ["in", ["A", "B"]].'
+ )
+ return None
+
def run_add_data_source(ctx, args: dict) -> str:
name = text_arg(args.get("data_source_name"))
@@ -28,6 +70,9 @@ def run_add_data_source(ctx, args: dict) -> str:
if source_type not in RESOURCE_TYPES:
return f"FAILED: data_source_type must be one of {list(RESOURCE_TYPES)}."
+ if error := invalid_filter_message(args.get("filters")):
+ return error
+
page = load_page(ctx)
if page is None:
return "FAILED: no page in context."
@@ -53,6 +98,8 @@ def run_list_data_sources(ctx, args: dict) -> str:
def run_update_data_source(ctx, args: dict) -> str:
name = text_arg(args.get("data_source_name"))
+ if error := invalid_filter_message(args.get("filters")):
+ return error
page = load_page(ctx)
if page is None:
return "FAILED: no page in context."