feat: Dynamic Placeholders UI (#7034)

This commit is contained in:
Michael Genson
2026-02-10 23:43:17 -06:00
committed by GitHub
parent 9b686ecd2b
commit b64f14aaae
11 changed files with 236 additions and 49 deletions

View File

@@ -16,6 +16,10 @@
max-width: 950px !important; max-width: 950px !important;
} }
.lg-container {
max-width: 1100px !important;
}
.theme--dark.v-application { .theme--dark.v-application {
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important; background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
} }

View File

@@ -76,7 +76,6 @@ const MEAL_DAY_OPTIONS = [
]; ];
function handleQueryFilterInput(value: string | undefined) { function handleQueryFilterInput(value: string | undefined) {
console.warn("handleQueryFilterInput called with value:", value);
queryFilterString.value = value || ""; queryFilterString.value = value || "";
} }
@@ -114,7 +113,7 @@ const fieldDefs: FieldDefinition[] = [
{ {
name: "last_made", name: "last_made",
label: i18n.t("general.last-made"), label: i18n.t("general.last-made"),
type: "date", type: "relativeDate",
}, },
{ {
name: "created_at", name: "created_at",

View File

@@ -108,7 +108,7 @@
<v-select <v-select
v-if="field.type !== 'boolean'" v-if="field.type !== 'boolean'"
:model-value="field.relationalOperatorValue" :model-value="field.relationalOperatorValue"
:items="field.relationalOperatorOptions" :items="field.relationalOperatorChoices"
item-title="label" item-title="label"
item-value="value" item-value="value"
variant="underlined" variant="underlined"
@@ -129,9 +129,9 @@
:class="config.col.class" :class="config.col.class"
> >
<v-select <v-select
v-if="field.fieldOptions" v-if="field.fieldChoices"
:model-value="field.values" :model-value="field.values"
:items="field.fieldOptions" :items="field.fieldChoices"
item-title="label" item-title="label"
item-value="value" item-value="value"
multiple multiple
@@ -169,23 +169,39 @@
> >
<template #activator="{ props: activatorProps }"> <template #activator="{ props: activatorProps }">
<v-text-field <v-text-field
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null" :model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
persistent-hint
:prepend-icon="$globals.icons.calendar"
variant="underlined" variant="underlined"
color="primary" color="primary"
class="date-input"
v-bind="activatorProps" v-bind="activatorProps"
readonly readonly
/> />
</template> </template>
<v-date-picker <v-date-picker
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null" :model-value="safeNewDate(field.value + 'T00:00:00')"
hide-header hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')" @update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
/> />
</v-menu> </v-menu>
<!--
Relative dates are assumed to be negative intervals with a unit of days.
The input is a *positive*, interpreted internally as a *negative* offset.
-->
<v-number-input
v-else-if="field.type === 'relativeDate'"
:model-value="parseRelativeDateOffset(field.value)"
:suffix="$t('query-filter.dates.days-ago', parseRelativeDateOffset(field.value))"
variant="underlined"
control-variant="stacked"
density="compact"
inset
:min="0"
:precision="0"
class="date-input"
@update:model-value="setFieldValue(field, index, $event)"
/>
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category" v-else-if="field.type === Organizer.Category"
v-model="field.organizers" v-model="field.organizers"
@@ -319,7 +335,13 @@ import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue"; import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated"; import type {
LogicalOperator,
QueryFilterJSON,
QueryFilterJSONPart,
RelationalKeyword,
RelationalOperator,
} from "~/lib/api/types/non-generated";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-store"; import { useUserStore } from "~/composables/store/use-user-store";
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder"; import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
@@ -341,7 +363,14 @@ const emit = defineEmits<{
}>(); }>();
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder(); const {
logOps,
placeholderKeywords,
getRelOps,
buildQueryFilterString,
getFieldFromFieldDef,
isOrganizerType,
} = useQueryFilterBuilder();
const firstDayOfWeek = computed(() => { const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0; return household.value?.preferences?.firstDayOfWeek || 0;
@@ -396,16 +425,29 @@ function setField(index: number, fieldLabel: string) {
return; return;
} }
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions); const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldChoices !== fields.value[index].fieldChoices);
const updatedField = { ...fields.value[index], ...fieldDef }; const updatedField = { ...fields.value[index], ...fieldDef };
// we have to set this explicitly since it might be undefined // we have to set this explicitly since it might be undefined
updatedField.fieldOptions = fieldDef.fieldOptions; updatedField.fieldChoices = fieldDef.fieldChoices;
fields.value[index] = { fields.value[index] = {
...getFieldFromFieldDef(updatedField, resetValue), ...getFieldFromFieldDef(updatedField, resetValue),
id: fields.value[index].id, // keep the id id: fields.value[index].id, // keep the id
}; };
// Defaults
switch (fields.value[index].type) {
case "date":
fields.value[index].value = safeNewDate("");
break;
case "relativeDate":
fields.value[index].value = "$NOW-30d";
break;
default:
break;
}
} }
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) { function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
@@ -425,12 +467,21 @@ function setLogicalOperatorValue(field: FieldWithId, index: number, value: Logic
} }
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) { function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
const relOps = getRelOps(field.type);
fields.value[index].relationalOperatorValue = relOps.value[value]; fields.value[index].relationalOperatorValue = relOps.value[value];
} }
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) { function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
state.datePickers[index] = false; state.datePickers[index] = false;
fields.value[index].value = value;
if (field.type === "relativeDate") {
// Value is set to an int representing the offset from $NOW
// Values are assumed to be negative offsets ('-') with a unit of days ('d')
fields.value[index].value = `$NOW-${Math.abs(value)}d`;
}
else {
fields.value[index].value = value;
}
} }
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) { function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
@@ -448,12 +499,7 @@ function removeField(index: number) {
state.datePickers.splice(index, 1); state.datePickers.splice(index, 1);
} }
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => { const fieldsUpdater = useDebounceFn(() => {
/* newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; // recursive!!!
}); */
const qf = buildQueryFilterString(fields.value, state.showAdvanced); const qf = buildQueryFilterString(fields.value, state.showAdvanced);
if (qf) { if (qf) {
console.debug(`Set query filter: ${qf}`); console.debug(`Set query filter: ${qf}`);
@@ -519,6 +565,9 @@ async function initializeFields() {
...getFieldFromFieldDef(fieldDef), ...getFieldFromFieldDef(fieldDef),
id: useUid(), id: useUid(),
}; };
const relOps = getRelOps(field.type);
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis; field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis; field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
field.logicalOperator = part.logicalOperator field.logicalOperator = part.logicalOperator
@@ -527,12 +576,15 @@ async function initializeFields() {
field.relationalOperatorValue = part.relationalOperator field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator] ? relOps.value[part.relationalOperator]
: field.relationalOperatorValue; : field.relationalOperatorValue;
field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator]
: field.relationalOperatorValue;
if (field.leftParenthesis || field.rightParenthesis) { if (field.leftParenthesis || field.rightParenthesis) {
state.showAdvanced = true; state.showAdvanced = true;
} }
if (field.fieldOptions?.length || isOrganizerType(field.type)) { if (field.fieldChoices?.length || isOrganizerType(field.type)) {
if (typeof part.value === "string") { if (typeof part.value === "string") {
field.values = part.value ? [part.value] : []; field.values = part.value ? [part.value] : [];
} }
@@ -601,7 +653,7 @@ function buildQueryFilterJSON(): QueryFilterJSON {
relationalOperator: field.relationalOperatorValue?.value, relationalOperator: field.relationalOperatorValue?.value,
}; };
if (field.fieldOptions?.length || isOrganizerType(field.type)) { if (field.fieldChoices?.length || isOrganizerType(field.type)) {
part.value = field.values.map(value => value.toString()); part.value = field.values.map(value => value.toString());
} }
else if (field.type === "boolean") { else if (field.type === "boolean") {
@@ -619,6 +671,50 @@ function buildQueryFilterJSON(): QueryFilterJSON {
return qfJSON; return qfJSON;
} }
function safeNewDate(input: string): Date {
const date = new Date(input);
if (isNaN(date.getTime())) {
const today = new Date();
today.setHours(0, 0, 0, 0);
return today;
}
return date;
}
/**
* Parse a relative date string offset (e.g. $NOW-30d --> 30)
*
* Currently only values with a negative offset ('-') and a unit of days ('d') are supported
*/
function parseRelativeDateOffset(value: string): number {
const defaultVal = 30;
if (!value) {
return defaultVal;
}
try {
if (!value.startsWith(placeholderKeywords.value["$NOW"].value)) {
return defaultVal;
}
const remainder = value.slice(placeholderKeywords.value["$NOW"].value.length);
if (!remainder.startsWith("-")) {
throw new Error("Invalid operator (not '-')");
}
if (remainder.slice(-1) !== "d") {
throw new Error("Invalid unit (not 'd')");
}
// Slice off sign and unit
return parseInt(remainder.slice(1, -1));
}
catch (error) {
console.warn(`Unable to parse relative date offset from '${value}': ${error}`);
return defaultVal;
}
}
const config = computed(() => { const config = computed(() => {
const multiple = fields.value.length > 1; const multiple = fields.value.length > 1;
const adv = state.showAdvanced; const adv = state.showAdvanced;
@@ -689,4 +785,13 @@ const config = computed(() => {
.bg-light { .bg-light {
background-color: rgba(255, 255, 255, var(--bg-opactity)); background-color: rgba(255, 255, 255, var(--bg-opactity));
} }
:deep(.date-input input) {
text-align: end;
padding-right: 6px;
}
:deep(.date-input .v-field__field) {
align-items: center;
}
</style> </style>

View File

@@ -1,5 +1,5 @@
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import type { LogicalOperator, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated"; import type { LogicalOperator, PlaceholderKeyword, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
export interface FieldLogicalOperator { export interface FieldLogicalOperator {
label: string; label: string;
@@ -11,6 +11,11 @@ export interface FieldRelationalOperator {
value: RelationalKeyword | RelationalOperator; value: RelationalKeyword | RelationalOperator;
} }
export interface FieldPlaceholderKeyword {
label: string;
value: PlaceholderKeyword;
}
export interface OrganizerBase { export interface OrganizerBase {
id: string; id: string;
slug: string; slug: string;
@@ -22,6 +27,7 @@ export type FieldType
| "number" | "number"
| "boolean" | "boolean"
| "date" | "date"
| "relativeDate"
| RecipeOrganizer; | RecipeOrganizer;
export type FieldValue export type FieldValue
@@ -41,8 +47,8 @@ export interface FieldDefinition {
label: string; label: string;
type: FieldType; type: FieldType;
// only for select/organizer fields // Select/Organizer
fieldOptions?: SelectableItem[]; fieldChoices?: SelectableItem[];
} }
export interface Field extends FieldDefinition { export interface Field extends FieldDefinition {
@@ -50,10 +56,10 @@ export interface Field extends FieldDefinition {
logicalOperator?: FieldLogicalOperator; logicalOperator?: FieldLogicalOperator;
value: FieldValue; value: FieldValue;
relationalOperatorValue: FieldRelationalOperator; relationalOperatorValue: FieldRelationalOperator;
relationalOperatorOptions: FieldRelationalOperator[]; relationalOperatorChoices: FieldRelationalOperator[];
rightParenthesis?: string; rightParenthesis?: string;
// only for select/organizer fields // Select/Organizer
values: FieldValue[]; values: FieldValue[];
organizers: OrganizerBase[]; organizers: OrganizerBase[];
} }
@@ -161,6 +167,36 @@ export function useQueryFilterBuilder() {
}; };
}); });
const placeholderKeywords = computed<Record<PlaceholderKeyword, FieldPlaceholderKeyword>>(() => {
const NOW = {
label: "Now",
value: "$NOW",
} as FieldPlaceholderKeyword;
return {
$NOW: NOW,
};
});
const relativeDateRelOps = computed<Record<RelationalKeyword | RelationalOperator, FieldRelationalOperator>>(() => {
const ops = { ...relOps.value };
ops[">="] = { ...relOps.value[">="], label: i18n.t("query-filter.relational-operators.is-newer-than") };
ops["<="] = { ...relOps.value["<="], label: i18n.t("query-filter.relational-operators.is-older-than") };
return ops;
});
function getRelOps(fieldType: FieldType): typeof relOps | typeof relativeDateRelOps {
switch (fieldType) {
case "relativeDate":
return relativeDateRelOps;
default:
return relOps;
}
}
function isOrganizerType(type: FieldType): type is Organizer { function isOrganizerType(type: FieldType): type is Organizer {
return ( return (
type === Organizer.Category type === Organizer.Category
@@ -173,10 +209,14 @@ export function useQueryFilterBuilder() {
}; };
function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field { function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field {
const updatedField = { logicalOperator: logOps.value.AND, ...field } as Field; const updatedField = {
let operatorOptions: FieldRelationalOperator[]; logicalOperator: logOps.value.AND,
if (updatedField.fieldOptions?.length || isOrganizerType(updatedField.type)) { ...field,
operatorOptions = [ } as Field;
let operatorChoices: FieldRelationalOperator[];
if (updatedField.fieldChoices?.length || isOrganizerType(updatedField.type)) {
operatorChoices = [
relOps.value["IN"], relOps.value["IN"],
relOps.value["NOT IN"], relOps.value["NOT IN"],
relOps.value["CONTAINS ALL"], relOps.value["CONTAINS ALL"],
@@ -185,7 +225,7 @@ export function useQueryFilterBuilder() {
else { else {
switch (updatedField.type) { switch (updatedField.type) {
case "string": case "string":
operatorOptions = [ operatorChoices = [
relOps.value["="], relOps.value["="],
relOps.value["<>"], relOps.value["<>"],
relOps.value["LIKE"], relOps.value["LIKE"],
@@ -193,7 +233,7 @@ export function useQueryFilterBuilder() {
]; ];
break; break;
case "number": case "number":
operatorOptions = [ operatorChoices = [
relOps.value["="], relOps.value["="],
relOps.value["<>"], relOps.value["<>"],
relOps.value[">"], relOps.value[">"],
@@ -203,10 +243,10 @@ export function useQueryFilterBuilder() {
]; ];
break; break;
case "boolean": case "boolean":
operatorOptions = [relOps.value["="]]; operatorChoices = [relOps.value["="]];
break; break;
case "date": case "date":
operatorOptions = [ operatorChoices = [
relOps.value["="], relOps.value["="],
relOps.value["<>"], relOps.value["<>"],
relOps.value[">"], relOps.value[">"],
@@ -215,13 +255,20 @@ export function useQueryFilterBuilder() {
relOps.value["<="], relOps.value["<="],
]; ];
break; break;
case "relativeDate":
operatorChoices = [
// "<=" is first since "older than" is the most common operator
relativeDateRelOps.value["<="],
relativeDateRelOps.value[">="],
];
break;
default: default:
operatorOptions = [relOps.value["="], relOps.value["<>"]]; operatorChoices = [relOps.value["="], relOps.value["<>"]];
} }
} }
updatedField.relationalOperatorOptions = operatorOptions; updatedField.relationalOperatorChoices = operatorChoices;
if (!operatorOptions.includes(updatedField.relationalOperatorValue)) { if (!operatorChoices.includes(updatedField.relationalOperatorValue)) {
updatedField.relationalOperatorValue = operatorOptions[0]; updatedField.relationalOperatorValue = operatorChoices[0];
} }
if (resetValue) { if (resetValue) {
@@ -271,7 +318,7 @@ export function useQueryFilterBuilder() {
isValid = false; isValid = false;
} }
if (field.fieldOptions?.length || isOrganizerType(field.type)) { if (field.fieldChoices?.length || isOrganizerType(field.type)) {
if (field.values?.length) { if (field.values?.length) {
let val: string; let val: string;
if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) { if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) {
@@ -316,7 +363,8 @@ export function useQueryFilterBuilder() {
return { return {
logOps, logOps,
relOps, placeholderKeywords,
getRelOps,
buildQueryFilterString, buildQueryFilterString,
getFieldFromFieldDef, getFieldFromFieldDef,
isOrganizerType, isOrganizerType,

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is greater than", "is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than", "is-less-than": "is less than",
"is-less-than-or-equal-to": "is less than or equal to" "is-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contains all of", "contains-all-of": "contains all of",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -41,8 +41,9 @@ export enum Organizer {
User = "users", User = "users",
} }
export type LogicalOperator = "AND" | "OR"; export type PlaceholderKeyword = "$NOW";
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE"; export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
export type LogicalOperator = "AND" | "OR";
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<="; export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
export interface QueryFilterJSON { export interface QueryFilterJSON {

View File

@@ -39,7 +39,7 @@
<!-- Cookbook Page --> <!-- Cookbook Page -->
<!-- Page Title --> <!-- Page Title -->
<v-container max-width="1000"> <v-container class="lg-container">
<BasePageTitle divider> <BasePageTitle divider>
<template #header> <template #header>
<v-img width="100%" max-height="100" max-width="100" src="/svgs/manage-cookbooks.svg" /> <v-img width="100%" max-height="100" max-width="100" src="/svgs/manage-cookbooks.svg" />

View File

@@ -664,6 +664,11 @@ export default defineNuxtComponent({
label: i18n.t("user.users"), label: i18n.t("user.users"),
type: Organizer.User, type: Organizer.User,
}, },
{
name: "last_made",
label: i18n.t("general.last-made"),
type: "relativeDate",
},
]; ];
function clearQueryFilter() { function clearQueryFilter() {

View File

@@ -1,5 +1,5 @@
<template> <template>
<v-container class="md-container"> <v-container class="lg-container">
<BasePageTitle divider> <BasePageTitle divider>
<template #header> <template #header>
<v-img <v-img

View File

@@ -39,6 +39,12 @@ class QueryFilterJSON(MealieModel):
class QueryFilterBuilderComponent: class QueryFilterBuilderComponent:
"""A single relational statement""" """A single relational statement"""
raw_value: str | list[str] | None
"""The raw value parsed from the query filter string, before processing placeholder keywords"""
value: str | list[str] | None
"""The value parsed from the query filter string, after processing placeholder keywords"""
@staticmethod @staticmethod
def strip_quotes_from_string(val: str) -> str: def strip_quotes_from_string(val: str) -> str:
if len(val) > 2 and val[0] == '"' and val[-1] == '"': if len(val) > 2 and val[0] == '"' and val[-1] == '"':
@@ -76,12 +82,12 @@ class QueryFilterBuilderComponent:
f'invalid query string: "{relationship.value}" can only be used with "NULL", not "{value}"' f'invalid query string: "{relationship.value}" can only be used with "NULL", not "{value}"'
) )
self.value = None self.raw_value = None
else: else:
self.value = value self.raw_value = value
# process placeholder keywords # process placeholder keywords
self.value = PlaceholderKeyword.parse_value(self.value) self.value = PlaceholderKeyword.parse_value(self.raw_value)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"[{self.attribute_name} {self.relationship.value} {self.value}]" return f"[{self.attribute_name} {self.relationship.value} {self.value}]"
@@ -138,7 +144,7 @@ class QueryFilterBuilderComponent:
logical_operator=None, logical_operator=None,
attribute_name=self.attribute_name, attribute_name=self.attribute_name,
relational_operator=self.relationship, relational_operator=self.relationship,
value=self.value, value=self.raw_value, # we use the raw value to preserve placeholder keywords
) )

View File

@@ -60,3 +60,17 @@ def test_query_filter_builder_json():
), ),
] ]
) )
def test_query_filter_builder_json_uses_raw_value():
qf = "last_made <= $NOW-30d"
builder = QueryFilterBuilder(qf)
assert builder.as_json_model() == QueryFilterJSON(
parts=[
QueryFilterJSONPart(
attribute_name="last_made",
relational_operator=RelationalOperator.LTE,
value="$NOW-30d",
),
]
)