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 @@ + + + + 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 @@ + + + + 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."