mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-12 02:43:12 -05:00
feat: Dynamic Placeholders UI (#7034)
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,13 +467,22 @@ 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;
|
||||||
|
|
||||||
|
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;
|
fields.value[index].value = value;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
||||||
fields.value[index].values = values;
|
fields.value[index].values = values;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user