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;
}
.lg-container {
max-width: 1100px !important;
}
.theme--dark.v-application {
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) {
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",

View File

@@ -108,7 +108,7 @@
<v-select
v-if="field.type !== 'boolean'"
:model-value="field.relationalOperatorValue"
:items="field.relationalOperatorOptions"
:items="field.relationalOperatorChoices"
item-title="label"
item-value="value"
variant="underlined"
@@ -129,9 +129,9 @@
:class="config.col.class"
>
<v-select
v-if="field.fieldOptions"
v-if="field.fieldChoices"
:model-value="field.values"
:items="field.fieldOptions"
:items="field.fieldChoices"
item-title="label"
item-value="value"
multiple
@@ -169,23 +169,39 @@
>
<template #activator="{ props: activatorProps }">
<v-text-field
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
persistent-hint
:prepend-icon="$globals.icons.calendar"
:model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
variant="underlined"
color="primary"
class="date-input"
v-bind="activatorProps"
readonly
/>
</template>
<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
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
/>
</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
v-else-if="field.type === Organizer.Category"
v-model="field.organizers"
@@ -319,7 +335,13 @@ import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
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 { useUserStore } from "~/composables/store/use-user-store";
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 { 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;
}
</style>

View File

@@ -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<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 {
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,

View File

@@ -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": {

View File

@@ -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 {

View File

@@ -39,7 +39,7 @@
<!-- Cookbook Page -->
<!-- Page Title -->
<v-container max-width="1000">
<v-container class="lg-container">
<BasePageTitle divider>
<template #header>
<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"),
type: Organizer.User,
},
{
name: "last_made",
label: i18n.t("general.last-made"),
type: "relativeDate",
},
];
function clearQueryFilter() {

View File

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

View File

@@ -39,6 +39,12 @@ class QueryFilterJSON(MealieModel):
class QueryFilterBuilderComponent:
"""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
def strip_quotes_from_string(val: str) -> str:
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}"'
)
self.value = None
self.raw_value = None
else:
self.value = value
self.raw_value = value
# process placeholder keywords
self.value = PlaceholderKeyword.parse_value(self.value)
self.value = PlaceholderKeyword.parse_value(self.raw_value)
def __repr__(self) -> str:
return f"[{self.attribute_name} {self.relationship.value} {self.value}]"
@@ -138,7 +144,7 @@ class QueryFilterBuilderComponent:
logical_operator=None,
attribute_name=self.attribute_name,
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",
),
]
)