From b64f14aaaebca3e3766dd3231b1c16cf7109c26e Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:43:17 -0600 Subject: [PATCH] feat: Dynamic Placeholders UI (#7034) --- frontend/assets/main.css | 4 + .../Household/GroupMealPlanRuleForm.vue | 3 +- .../components/Domain/QueryFilterBuilder.vue | 145 +++++++++++++++--- .../composables/use-query-filter-builder.ts | 86 ++++++++--- frontend/lang/messages/en-US.json | 7 +- frontend/lib/api/types/non-generated.ts | 3 +- .../pages/g/[groupSlug]/cookbooks/index.vue | 2 +- .../g/[groupSlug]/recipes/finder/index.vue | 5 + .../pages/household/mealplan/settings.vue | 2 +- mealie/services/query_filter/builder.py | 14 +- .../test_query_filter_builder.py | 14 ++ 11 files changed, 236 insertions(+), 49 deletions(-) diff --git a/frontend/assets/main.css b/frontend/assets/main.css index 05572da09..ec82ea5a3 100644 --- a/frontend/assets/main.css +++ b/frontend/assets/main.css @@ -16,6 +16,10 @@ max-width: 950px !important; } +.lg-container { + max-width: 1100px !important; +} + .theme--dark.v-application { background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important; } diff --git a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue index 5539f966a..5b817abb3 100644 --- a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue +++ b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue @@ -76,7 +76,6 @@ const MEAL_DAY_OPTIONS = [ ]; function handleQueryFilterInput(value: string | undefined) { - console.warn("handleQueryFilterInput called with value:", value); queryFilterString.value = value || ""; } @@ -114,7 +113,7 @@ const fieldDefs: FieldDefinition[] = [ { name: "last_made", label: i18n.t("general.last-made"), - type: "date", + type: "relativeDate", }, { name: "created_at", diff --git a/frontend/components/Domain/QueryFilterBuilder.vue b/frontend/components/Domain/QueryFilterBuilder.vue index ca330cbb4..7eb3a1000 100644 --- a/frontend/components/Domain/QueryFilterBuilder.vue +++ b/frontend/components/Domain/QueryFilterBuilder.vue @@ -108,7 +108,7 @@ + + (); const { household } = useHouseholdSelf(); -const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder(); +const { + logOps, + placeholderKeywords, + getRelOps, + buildQueryFilterString, + getFieldFromFieldDef, + isOrganizerType, +} = useQueryFilterBuilder(); const firstDayOfWeek = computed(() => { return household.value?.preferences?.firstDayOfWeek || 0; @@ -396,16 +425,29 @@ function setField(index: number, fieldLabel: string) { 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 }; // we have to set this explicitly since it might be undefined - updatedField.fieldOptions = fieldDef.fieldOptions; + updatedField.fieldChoices = fieldDef.fieldChoices; fields.value[index] = { ...getFieldFromFieldDef(updatedField, resetValue), 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) { @@ -425,12 +467,21 @@ function setLogicalOperatorValue(field: FieldWithId, index: number, value: Logic } function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) { + const relOps = getRelOps(field.type); fields.value[index].relationalOperatorValue = relOps.value[value]; } function setFieldValue(field: FieldWithId, index: number, value: FieldValue) { 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[]) { @@ -448,12 +499,7 @@ function removeField(index: number) { state.datePickers.splice(index, 1); } -const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => { - /* newFields.forEach((field, index) => { - const updatedField = getFieldFromFieldDef(field); - fields.value[index] = updatedField; // recursive!!! - }); */ - +const fieldsUpdater = useDebounceFn(() => { const qf = buildQueryFilterString(fields.value, state.showAdvanced); if (qf) { console.debug(`Set query filter: ${qf}`); @@ -519,6 +565,9 @@ async function initializeFields() { ...getFieldFromFieldDef(fieldDef), id: useUid(), }; + + const relOps = getRelOps(field.type); + field.leftParenthesis = part.leftParenthesis || field.leftParenthesis; field.rightParenthesis = part.rightParenthesis || field.rightParenthesis; field.logicalOperator = part.logicalOperator @@ -527,12 +576,15 @@ async function initializeFields() { field.relationalOperatorValue = part.relationalOperator ? relOps.value[part.relationalOperator] : field.relationalOperatorValue; + field.relationalOperatorValue = part.relationalOperator + ? relOps.value[part.relationalOperator] + : field.relationalOperatorValue; if (field.leftParenthesis || field.rightParenthesis) { state.showAdvanced = true; } - if (field.fieldOptions?.length || isOrganizerType(field.type)) { + if (field.fieldChoices?.length || isOrganizerType(field.type)) { if (typeof part.value === "string") { field.values = part.value ? [part.value] : []; } @@ -601,7 +653,7 @@ function buildQueryFilterJSON(): QueryFilterJSON { 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()); } else if (field.type === "boolean") { @@ -619,6 +671,50 @@ function buildQueryFilterJSON(): QueryFilterJSON { 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 multiple = fields.value.length > 1; const adv = state.showAdvanced; @@ -689,4 +785,13 @@ const config = computed(() => { .bg-light { 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; +} diff --git a/frontend/composables/use-query-filter-builder.ts b/frontend/composables/use-query-filter-builder.ts index 2b731cff3..f54720f26 100644 --- a/frontend/composables/use-query-filter-builder.ts +++ b/frontend/composables/use-query-filter-builder.ts @@ -1,5 +1,5 @@ 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 { label: string; @@ -11,6 +11,11 @@ export interface FieldRelationalOperator { value: RelationalKeyword | RelationalOperator; } +export interface FieldPlaceholderKeyword { + label: string; + value: PlaceholderKeyword; +} + export interface OrganizerBase { id: string; slug: string; @@ -22,6 +27,7 @@ export type FieldType | "number" | "boolean" | "date" + | "relativeDate" | RecipeOrganizer; export type FieldValue @@ -41,8 +47,8 @@ export interface FieldDefinition { label: string; type: FieldType; - // only for select/organizer fields - fieldOptions?: SelectableItem[]; + // Select/Organizer + fieldChoices?: SelectableItem[]; } export interface Field extends FieldDefinition { @@ -50,10 +56,10 @@ export interface Field extends FieldDefinition { logicalOperator?: FieldLogicalOperator; value: FieldValue; relationalOperatorValue: FieldRelationalOperator; - relationalOperatorOptions: FieldRelationalOperator[]; + relationalOperatorChoices: FieldRelationalOperator[]; rightParenthesis?: string; - // only for select/organizer fields + // Select/Organizer values: FieldValue[]; organizers: OrganizerBase[]; } @@ -161,6 +167,36 @@ export function useQueryFilterBuilder() { }; }); + const placeholderKeywords = computed>(() => { + const NOW = { + label: "Now", + value: "$NOW", + } as FieldPlaceholderKeyword; + + return { + $NOW: NOW, + }; + }); + + const relativeDateRelOps = computed>(() => { + 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 { return ( type === Organizer.Category @@ -173,10 +209,14 @@ export function useQueryFilterBuilder() { }; function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field { - const updatedField = { logicalOperator: logOps.value.AND, ...field } as Field; - let operatorOptions: FieldRelationalOperator[]; - if (updatedField.fieldOptions?.length || isOrganizerType(updatedField.type)) { - operatorOptions = [ + const updatedField = { + logicalOperator: logOps.value.AND, + ...field, + } as Field; + + let operatorChoices: FieldRelationalOperator[]; + if (updatedField.fieldChoices?.length || isOrganizerType(updatedField.type)) { + operatorChoices = [ relOps.value["IN"], relOps.value["NOT IN"], relOps.value["CONTAINS ALL"], @@ -185,7 +225,7 @@ export function useQueryFilterBuilder() { else { switch (updatedField.type) { case "string": - operatorOptions = [ + operatorChoices = [ relOps.value["="], relOps.value["<>"], relOps.value["LIKE"], @@ -193,7 +233,7 @@ export function useQueryFilterBuilder() { ]; break; case "number": - operatorOptions = [ + operatorChoices = [ relOps.value["="], relOps.value["<>"], relOps.value[">"], @@ -203,10 +243,10 @@ export function useQueryFilterBuilder() { ]; break; case "boolean": - operatorOptions = [relOps.value["="]]; + operatorChoices = [relOps.value["="]]; break; case "date": - operatorOptions = [ + operatorChoices = [ relOps.value["="], relOps.value["<>"], relOps.value[">"], @@ -215,13 +255,20 @@ export function useQueryFilterBuilder() { relOps.value["<="], ]; break; + case "relativeDate": + operatorChoices = [ + // "<=" is first since "older than" is the most common operator + relativeDateRelOps.value["<="], + relativeDateRelOps.value[">="], + ]; + break; default: - operatorOptions = [relOps.value["="], relOps.value["<>"]]; + operatorChoices = [relOps.value["="], relOps.value["<>"]]; } } - updatedField.relationalOperatorOptions = operatorOptions; - if (!operatorOptions.includes(updatedField.relationalOperatorValue)) { - updatedField.relationalOperatorValue = operatorOptions[0]; + updatedField.relationalOperatorChoices = operatorChoices; + if (!operatorChoices.includes(updatedField.relationalOperatorValue)) { + updatedField.relationalOperatorValue = operatorChoices[0]; } if (resetValue) { @@ -271,7 +318,7 @@ export function useQueryFilterBuilder() { isValid = false; } - if (field.fieldOptions?.length || isOrganizerType(field.type)) { + if (field.fieldChoices?.length || isOrganizerType(field.type)) { if (field.values?.length) { let val: string; if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) { @@ -316,7 +363,8 @@ export function useQueryFilterBuilder() { return { logOps, - relOps, + placeholderKeywords, + getRelOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType, diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index f1e5d0453..77d831619 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -1422,7 +1422,9 @@ "is-greater-than": "is greater than", "is-greater-than-or-equal-to": "is greater than or equal to", "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": { "is": "is", @@ -1432,6 +1434,9 @@ "contains-all-of": "contains all of", "is-like": "is like", "is-not-like": "is not like" + }, + "dates": { + "days-ago": "days ago|day ago|days ago" } }, "validators": { diff --git a/frontend/lib/api/types/non-generated.ts b/frontend/lib/api/types/non-generated.ts index d1acd7d5c..84f9ff7fe 100644 --- a/frontend/lib/api/types/non-generated.ts +++ b/frontend/lib/api/types/non-generated.ts @@ -41,8 +41,9 @@ export enum Organizer { 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 LogicalOperator = "AND" | "OR"; export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<="; export interface QueryFilterJSON { diff --git a/frontend/pages/g/[groupSlug]/cookbooks/index.vue b/frontend/pages/g/[groupSlug]/cookbooks/index.vue index 9ecfd96e4..9d46249a2 100644 --- a/frontend/pages/g/[groupSlug]/cookbooks/index.vue +++ b/frontend/pages/g/[groupSlug]/cookbooks/index.vue @@ -39,7 +39,7 @@ - +