Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions frontend/src/components/Filters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,35 @@
<FormControl
type="select"
:modelValue="filter.operator"
@update:modelValue="filter.operator = $event"
@update:modelValue="setOperator(filter, $event)"
:options="getOperators(filter.field.fieldtype)"
placeholder="Operator"
/>
</div>
<div id="value" class="flex-1">
<!-- in / not in: picked values for option fields, comma text otherwise -->
<MultiSelectInput
v-if="isMultiValueOperator(filter.operator) && typeSelect.includes(filter.field.fieldtype)"
:field="filter.field"
:modelValue="filter.value"
@update:modelValue="filter.value = $event"
/>
<MultiLinkInput
v-else-if="isMultiValueOperator(filter.operator) && typeLink.includes(filter.field.fieldtype)"
:field="filter.field"
:modelValue="filter.value"
@update:modelValue="filter.value = $event"
/>
<FormControl
v-else-if="isMultiValueOperator(filter.operator)"
type="text"
:modelValue="Array.isArray(filter.value) ? filter.value.join(', ') : (filter.value ?? '')"
@update:modelValue="filter.value = $event"
placeholder="Comma-separated values"
autocomplete="off"
/>
<Link
v-if="typeLink.includes(filter.field.fieldtype) && ['=', '!='].includes(filter.operator)"
v-else-if="typeLink.includes(filter.field.fieldtype) && ['=', '!='].includes(filter.operator)"
:doctype="filter.field.options as string"
:modelValue="filter.value"
@update:modelValue="filter.value = $event"
Expand Down Expand Up @@ -84,6 +105,8 @@ import { computed, h, ref, watch } from "vue"
import { Link } from "frappe-ui/frappe"

import FormInputLabel from "@/components/FormInputLabel.vue"
import MultiLinkInput from "@/components/MultiLinkInput.vue"
import MultiSelectInput from "@/components/MultiSelectInput.vue"
import type { DocTypeField, Fieldtype, Filter, Operators } from "@/types"
import { isObjectEmpty } from "@/utils/helpers"
import type { Filters } from "@/types/Studio/StudioResource"
Expand Down Expand Up @@ -151,7 +174,18 @@ function makeFiltersList(filtersDict: Filters) {
if (!field) {
throw new Error(`Field not found: ${fieldname}`)
}
const [operator, value] = Array.isArray(rawFilter) ? rawFilter : ["=" as Operators, rawFilter]
// A stored list filter is [operator, value]. A flat [op, v1, v2, ...] is a
// malformed multi-value filter — recover every value instead of silently
// dropping the tail.
let operator: Operators = "="
let value: Filter["value"] = rawFilter as Filter["value"]
if (Array.isArray(rawFilter)) {
operator = rawFilter[0] as Operators
value = rawFilter.length > 2 ? rawFilter.slice(1) : rawFilter[1]
}
if (isMultiValueOperator(operator) && !Array.isArray(value)) {
value = splitCommaValues(String(value ?? ""))
}
return {
fieldname,
operator,
Expand All @@ -169,11 +203,42 @@ function makeFiltersDict(filtersList: Filter[]) {
if (!filtersList.length) return {}
return filtersList.reduce((acc: Record<string, any>, 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)) {
Expand All @@ -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" },
],
)
}
Expand All @@ -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" },
],
)
}
Expand All @@ -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" },
],
)
}
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/components/MultiLinkInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<!--
The `in` / `not in` value input for a Link field: a frappe-ui MultiSelect whose
options are searched live from the link's target doctype (`search_link`, the same
endpoint the Link control uses), so the user picks real records instead of typing
a comma string of names. Its v-model is the condition's value — a string[] of the
selected record names. Ported from @framework/ui's Filter module.

Records already selected but absent from the current search results are merged
back into the options (from a remembered cache) so their chips stay labelled as
the query narrows the list.
-->
<template>
<!-- w-full lands on the trigger button (attrs.class passthrough) so the control
fills its flex column instead of sizing to its content -->
<MultiSelect
class="w-full"
:modelValue="selected"
:options="options"
:loading="resource.loading && !resource.data"
:placeholder="placeholder ?? `Search ${(field.options ?? '').toLowerCase()}`"
variant="subtle"
emptyText="No results found"
@update:modelValue="(v) => emit('update:modelValue', v)"
@update:query="onQuery"
@update:open="onOpen"
/>
</template>

<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { MultiSelect, createResource, frappeRequest, debounce } from "frappe-ui"
import type { DocTypeField } from "@/types"

const props = defineProps<{
field: DocTypeField
modelValue?: string | string[] | null
placeholder?: string
}>()

const emit = defineEmits<{ "update:modelValue": [value: string[]] }>()

interface LinkOption {
label: string
value: string
description?: string
}

const selected = computed<string[]>(() => (Array.isArray(props.modelValue) ? props.modelValue : []))

// Every option we've ever seen, so a selected-but-filtered-out record keeps its
// label after the query narrows the result set.
const known = ref(new Map<string, LinkOption>())

const resource = createResource({
url: "frappe.desk.search.search_link",
params: { doctype: props.field.options ?? "", txt: "", filters: {} },
method: "POST",
resourceFetcher: frappeRequest,
transform: (data: { value: string; label?: string; description?: string }[]): LinkOption[] =>
data.map((doc) => ({
label: doc.label || doc.value,
value: doc.value,
description: doc.description,
})),
})

watch(
() => resource.data as LinkOption[] | undefined,
(data) => {
for (const o of data ?? []) known.value.set(o.value, o)
},
)

const options = computed<LinkOption[]>(() => {
const byId = new Map<string, LinkOption>()
for (const o of (resource.data as LinkOption[]) ?? []) byId.set(o.value, o)
// Merge selected-but-absent values so their chips stay resolvable.
for (const v of selected.value) {
if (!byId.has(v)) byId.set(v, known.value.get(v) ?? { label: v, value: v })
}
return Array.from(byId.values())
})

function load(txt = "") {
if (!props.field.options) return
resource.update({ params: { doctype: props.field.options, txt, filters: {} } })
resource.reload()
}

const onQuery = debounce((q: string) => load(q || ""), 300)

function onOpen(isOpen: boolean) {
if (isOpen && !resource.data) load("")
}
</script>
45 changes: 45 additions & 0 deletions frontend/src/components/MultiSelectInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!--
The `in` / `not in` value input for a Select field: a frappe-ui MultiSelect over
the field's newline-joined meta options, so the user picks exact values instead
of typing an error-prone comma string. Its v-model is the condition's value — a
string[] of the chosen options. Ported from @framework/ui's Filter module.
-->
<template>
<!-- w-full lands on the trigger button (attrs.class passthrough) so the control
fills its flex column instead of sizing to its content -->
<MultiSelect
class="w-full"
:modelValue="selected"
:options="options"
:placeholder="placeholder ?? 'Select options'"
variant="subtle"
@update:modelValue="(v) => emit('update:modelValue', v)"
/>
</template>

<script setup lang="ts">
import { computed } from "vue"
import { MultiSelect } from "frappe-ui"
import type { DocTypeField } from "@/types"

const props = defineProps<{
field: DocTypeField
modelValue?: string | string[] | null
placeholder?: string
}>()

const emit = defineEmits<{ "update:modelValue": [value: string[]] }>()

// Tolerate a stray scalar (e.g. a value left over from a prior operator) — the
// MultiSelect only ever speaks string[].
const selected = computed<string[]>(() => (Array.isArray(props.modelValue) ? props.modelValue : []))

/** Frappe `Select` options are a newline-joined string in meta. */
const options = computed(() =>
(props.field.options ?? "")
.split("\n")
.map((o) => o.trim())
.filter(Boolean)
.map((o) => ({ label: o, value: o })),
)
</script>
28 changes: 22 additions & 6 deletions frontend/src/stores/codeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, any> | string | null = null) {
if (!params) return null
if (typeof params === "string") {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
47 changes: 47 additions & 0 deletions studio/ai/agent/tools/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}", <one value>], 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"))
Expand All @@ -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."
Expand All @@ -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."
Expand Down
Loading