mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-25 08:09:41 +02:00
feat: Query Filter Builder for Cookbooks and Meal Plans (#4346)
This commit is contained in:
parent
2a9a6fa5e6
commit
b8e62ab8dd
47 changed files with 2043 additions and 440 deletions
|
@ -1,11 +1,13 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-card-text v-if="cookbook">
|
||||
<v-card-text v-if="cookbook" class="px-1">
|
||||
<v-text-field v-model="cookbook.name" :label="$t('cookbook.cookbook-name')"></v-text-field>
|
||||
<v-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea>
|
||||
<RecipeOrganizerSelector v-model="cookbook.categories" selector-type="categories" />
|
||||
<RecipeOrganizerSelector v-model="cookbook.tags" selector-type="tags" />
|
||||
<RecipeOrganizerSelector v-model="cookbook.tools" selector-type="tools" />
|
||||
<QueryFilterBuilder
|
||||
:field-defs="fieldDefs"
|
||||
:initial-query-filter="cookbook.queryFilter"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<v-switch v-model="cookbook.public" hide-details single-line>
|
||||
<template #label>
|
||||
{{ $t('cookbook.public-cookbook') }}
|
||||
|
@ -14,33 +16,19 @@
|
|||
</HelpIcon>
|
||||
</template>
|
||||
</v-switch>
|
||||
<div class="mt-4">
|
||||
<h3 class="text-subtitle-1 d-flex align-center mb-0 pb-0">
|
||||
{{ $t('cookbook.filter-options') }}
|
||||
<HelpIcon right small class="ml-2">
|
||||
{{ $t('cookbook.filter-options-description') }}
|
||||
</HelpIcon>
|
||||
</h3>
|
||||
<v-switch v-model="cookbook.requireAllCategories" class="mt-0" hide-details single-line>
|
||||
<template #label> {{ $t('cookbook.require-all-categories') }} </template>
|
||||
</v-switch>
|
||||
<v-switch v-model="cookbook.requireAllTags" hide-details single-line>
|
||||
<template #label> {{ $t('cookbook.require-all-tags') }} </template>
|
||||
</v-switch>
|
||||
<v-switch v-model="cookbook.requireAllTools" hide-details single-line>
|
||||
<template #label> {{ $t('cookbook.require-all-tools') }} </template>
|
||||
</v-switch>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
|
||||
export default defineComponent({
|
||||
components: { RecipeOrganizerSelector },
|
||||
components: { QueryFilterBuilder },
|
||||
props: {
|
||||
cookbook: {
|
||||
type: Object as () => ReadCookBook,
|
||||
|
@ -51,5 +39,50 @@ export default defineComponent({
|
|||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
|
||||
function handleInput(value: string | undefined) {
|
||||
props.cookbook.queryFilterString = value || "";
|
||||
}
|
||||
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.tc("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.tc("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.tc("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.tc("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.tc("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.tc("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
handleInput,
|
||||
fieldDefs,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -4,11 +4,13 @@
|
|||
<BaseDialog
|
||||
v-if="editTarget"
|
||||
v-model="dialogStates.edit"
|
||||
:width="650"
|
||||
width="100%"
|
||||
max-width="1100px"
|
||||
:icon="$globals.icons.pages"
|
||||
:title="$t('general.edit')"
|
||||
:submit-icon="$globals.icons.save"
|
||||
:submit-text="$tc('general.save')"
|
||||
:submit-disabled="!editTarget.queryFilterString"
|
||||
@submit="editCookbook"
|
||||
>
|
||||
<v-card-text>
|
||||
|
|
|
@ -6,12 +6,10 @@
|
|||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
|
||||
<RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
|
||||
<GroupHouseholdSelector
|
||||
v-model="inputHouseholds"
|
||||
multiselect
|
||||
:description="$tc('meal-plan.mealplan-households-description')"
|
||||
<QueryFilterBuilder
|
||||
:field-defs="fieldDefs"
|
||||
:initial-query-filter="queryFilter"
|
||||
@input="handleQueryFilterInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -25,14 +23,14 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||
import GroupHouseholdSelector from "~/components/Domain/Household/GroupHouseholdSelector.vue";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { PlanCategory, PlanHousehold, PlanTag } from "~/lib/api/types/meal-plan";
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
GroupHouseholdSelector,
|
||||
RecipeOrganizerSelector,
|
||||
QueryFilterBuilder,
|
||||
},
|
||||
props: {
|
||||
day: {
|
||||
|
@ -43,17 +41,13 @@ export default defineComponent({
|
|||
type: String,
|
||||
default: "unset",
|
||||
},
|
||||
categories: {
|
||||
type: Array as () => PlanCategory[],
|
||||
default: () => [],
|
||||
queryFilterString: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
tags: {
|
||||
type: Array as () => PlanTag[],
|
||||
default: () => [],
|
||||
},
|
||||
households: {
|
||||
type: Array as () => PlanHousehold[],
|
||||
default: () => [],
|
||||
queryFilter: {
|
||||
type: Object as () => QueryFilterJSON,
|
||||
default: null,
|
||||
},
|
||||
showHelp: {
|
||||
type: Boolean,
|
||||
|
@ -100,41 +94,65 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
|
||||
const inputCategories = computed({
|
||||
const inputQueryFilterString = computed({
|
||||
get: () => {
|
||||
return props.categories;
|
||||
return props.queryFilterString;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:categories", val);
|
||||
context.emit("update:query-filter-string", val);
|
||||
},
|
||||
});
|
||||
|
||||
const inputTags = computed({
|
||||
get: () => {
|
||||
return props.tags;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:tags", val);
|
||||
},
|
||||
});
|
||||
function handleQueryFilterInput(value: string | undefined) {
|
||||
inputQueryFilterString.value = value || "";
|
||||
};
|
||||
|
||||
const inputHouseholds = computed({
|
||||
get: () => {
|
||||
return props.households;
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.tc("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:households", val);
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.tc("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
});
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.tc("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.tc("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.tc("general.last-made"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.tc("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.tc("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
MEAL_TYPE_OPTIONS,
|
||||
MEAL_DAY_OPTIONS,
|
||||
inputDay,
|
||||
inputEntryType,
|
||||
inputCategories,
|
||||
inputTags,
|
||||
inputHouseholds,
|
||||
inputQueryFilterString,
|
||||
handleQueryFilterInput,
|
||||
fieldDefs,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
622
frontend/components/Domain/QueryFilterBuilder.vue
Normal file
622
frontend/components/Domain/QueryFilterBuilder.vue
Normal file
|
@ -0,0 +1,622 @@
|
|||
<template>
|
||||
<v-card class="ma-0" style="overflow-x: auto;">
|
||||
<v-card-text class="ma-0 pa-0">
|
||||
<v-container fluid class="ma-0 pa-0">
|
||||
<draggable
|
||||
:value="fields"
|
||||
handle=".handle"
|
||||
v-bind="{
|
||||
animation: 200,
|
||||
group: 'recipe-instructions',
|
||||
ghostClass: 'ghost',
|
||||
}"
|
||||
@start="drag = true"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<v-row
|
||||
v-for="(field, index) in fields"
|
||||
:key="index"
|
||||
class="d-flex flex-nowrap"
|
||||
style="max-width: 100%;"
|
||||
>
|
||||
<v-col
|
||||
:cols="attrs.fields.icon.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.icon.style"
|
||||
>
|
||||
<v-icon
|
||||
class="handle"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.logicalOperator.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.logicalOperator.style"
|
||||
>
|
||||
<v-select
|
||||
v-if="index"
|
||||
v-model="field.logicalOperator"
|
||||
:items="[logOps.AND, logOps.OR]"
|
||||
item-text="label"
|
||||
item-value="value"
|
||||
@input="setLogicalOperatorValue(field, index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="showAdvanced"
|
||||
:cols="attrs.fields.leftParens.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.leftParens.style"
|
||||
>
|
||||
<v-select
|
||||
v-model="field.leftParenthesis"
|
||||
:items="['', '(', '((', '(((']"
|
||||
@input="setLeftParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.fieldName.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.fieldName.style"
|
||||
>
|
||||
<v-select
|
||||
v-model="field.label"
|
||||
:items="fieldDefs"
|
||||
item-text="label"
|
||||
@change="setField(index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.relationalOperator.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.relationalOperator.style"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
v-model="field.relationalOperatorValue"
|
||||
:items="field.relationalOperatorOptions"
|
||||
item-text="label"
|
||||
item-value="value"
|
||||
@input="setRelationalOperatorValue(field, index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.fieldValue.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.fieldValue.style"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.fieldOptions"
|
||||
v-model="field.values"
|
||||
:items="field.fieldOptions"
|
||||
item-text="label"
|
||||
item-value="value"
|
||||
multiple
|
||||
@input="setFieldValues(field, index, $event)"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'string'"
|
||||
v-model="field.value"
|
||||
@input="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
@input="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-else-if="field.type === 'boolean'"
|
||||
v-model="field.value"
|
||||
@change="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-menu
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="datePickers[index]"
|
||||
:close-on-content-click="false"
|
||||
transition="scale-transition"
|
||||
offset-y
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ on, attrs: menuAttrs }">
|
||||
<v-text-field
|
||||
v-model="field.value"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="menuAttrs"
|
||||
readonly
|
||||
v-on="on"
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="field.value"
|
||||
no-title
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@input="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
</v-menu>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Category"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tag"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tag"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tool"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tool"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Food"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Food"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Household"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Household"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="showAdvanced"
|
||||
:cols="attrs.fields.rightParens.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.rightParens.style"
|
||||
>
|
||||
<v-select
|
||||
v-model="field.rightParenthesis"
|
||||
:items="['', ')', '))', ')))']"
|
||||
@input="setRightParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.fieldActions.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.fieldActions.style"
|
||||
>
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $tc('general.delete'),
|
||||
event: 'delete',
|
||||
disabled: fields.length === 1,
|
||||
}
|
||||
]"
|
||||
class="my-auto"
|
||||
@delete="removeField(index)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</draggable>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-container fluid class="d-flex justify-end pa-0">
|
||||
<v-checkbox
|
||||
v-model="showAdvanced"
|
||||
hide-details
|
||||
:label="$tc('general.show-advanced')"
|
||||
class="my-auto mr-4"
|
||||
/>
|
||||
<BaseButton create :text="$tc('general.add-field')" @click="addField(fieldDefs[0])" />
|
||||
</v-container>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
import { computed, defineComponent, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { Field, FieldDefinition, FieldValue, OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
draggable,
|
||||
RecipeOrganizerSelector,
|
||||
},
|
||||
props: {
|
||||
fieldDefs: {
|
||||
type: Array as () => FieldDefinition[],
|
||||
required: true,
|
||||
},
|
||||
initialQueryFilter: {
|
||||
type: Object as () => QueryFilterJSON | null,
|
||||
default: null,
|
||||
}
|
||||
},
|
||||
setup(props, context) {
|
||||
const { household } = useHouseholdSelf();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
showAdvanced: false,
|
||||
qfValid: false,
|
||||
datePickers: [] as boolean[],
|
||||
drag: false,
|
||||
});
|
||||
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
};
|
||||
|
||||
function onDragEnd(event: any) {
|
||||
state.drag = false;
|
||||
|
||||
const oldIndex: number = event.oldIndex;
|
||||
const newIndex: number = event.newIndex;
|
||||
state.datePickers[oldIndex] = false;
|
||||
state.datePickers[newIndex] = false;
|
||||
|
||||
const field = fields.value.splice(oldIndex, 1)[0];
|
||||
fields.value.splice(newIndex, 0, field);
|
||||
}
|
||||
|
||||
const fields = ref<Field[]>([]);
|
||||
|
||||
function addField(field: FieldDefinition) {
|
||||
fields.value.push(getFieldFromFieldDef(field));
|
||||
state.datePickers.push(false);
|
||||
};
|
||||
|
||||
function setField(index: number, fieldLabel: string) {
|
||||
state.datePickers[index] = false;
|
||||
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.label === fieldLabel);
|
||||
if (!fieldDef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||
const updatedField = {...fields.value[index], ...fieldDef};
|
||||
|
||||
// we have to set this explicitly since it might be undefined
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
|
||||
fields.value.splice(index, 1, getFieldFromFieldDef(updatedField, resetValue));
|
||||
}
|
||||
|
||||
function setLeftParenthesisValue(field: Field, index: number, value: string) {
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
leftParenthesis: value,
|
||||
});
|
||||
}
|
||||
|
||||
function setRightParenthesisValue(field: Field, index: number, value: string) {
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
rightParenthesis: value,
|
||||
});
|
||||
}
|
||||
|
||||
function setLogicalOperatorValue(field: Field, index: number, value: LogicalOperator | undefined) {
|
||||
if (!value) {
|
||||
value = logOps.value.AND.value;
|
||||
}
|
||||
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
logicalOperator: value ? logOps.value[value] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function setRelationalOperatorValue(field: Field, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
relationalOperatorValue: relOps.value[value],
|
||||
});
|
||||
}
|
||||
|
||||
function setFieldValue(field: Field, index: number, value: FieldValue) {
|
||||
state.datePickers[index] = false;
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function setFieldValues(field: Field, index: number, values: FieldValue[]) {
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
function setOrganizerValues(field: Field, index: number, values: OrganizerBase[]) {
|
||||
setFieldValues(field, index, values.map((value) => value.id.toString()));
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
fields.value.splice(index, 1);
|
||||
state.datePickers.splice(index, 1);
|
||||
};
|
||||
|
||||
watch(
|
||||
// Toggling showAdvanced changes the builder logic without changing the field values,
|
||||
// so we need to manually trigger reactivity to re-run the builder.
|
||||
() => state.showAdvanced,
|
||||
() => {
|
||||
if (fields.value?.length) {
|
||||
fields.value = [...fields.value];
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => fields.value,
|
||||
(newFields) => {
|
||||
newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField;
|
||||
});
|
||||
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
console.debug(`Set query filter: ${qf}`);
|
||||
}
|
||||
state.qfValid = !!qf;
|
||||
|
||||
context.emit("input", qf || undefined);
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
},
|
||||
);
|
||||
|
||||
async function hydrateOrganizers(field: Field, index: number) {
|
||||
if (!field.values?.length || !isOrganizerType(field.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
field.organizers = [];
|
||||
|
||||
const { store, actions } = storeMap[field.type];
|
||||
if (!store.value.length) {
|
||||
await actions.refresh();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
const organizers = field.values.map((value) => store.value.find((organizer) => organizer.id === value));
|
||||
field.organizers = organizers.filter((organizer) => organizer !== undefined) as OrganizerBase[];
|
||||
setOrganizerValues(field, index, field.organizers);
|
||||
}
|
||||
|
||||
function initFieldsError(error = "") {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
fields.value = [];
|
||||
if (props.fieldDefs.length) {
|
||||
addField(props.fieldDefs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFields() {
|
||||
if (!props.initialQueryFilter?.parts?.length) {
|
||||
return initFieldsError();
|
||||
};
|
||||
|
||||
const initFields: Field[] = [];
|
||||
let error = false;
|
||||
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
|
||||
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.name === part.attributeName);
|
||||
if (!fieldDef) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
|
||||
}
|
||||
|
||||
const field = getFieldFromFieldDef(fieldDef);
|
||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||
field.logicalOperator = part.logicalOperator ?
|
||||
logOps.value[part.logicalOperator] : field.logicalOperator;
|
||||
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 (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
} else {
|
||||
field.values = part.value || [];
|
||||
}
|
||||
|
||||
if (isOrganizerType(field.type)) {
|
||||
hydrateOrganizers(field, index);
|
||||
}
|
||||
|
||||
} else if (field.type === "boolean") {
|
||||
const boolString = part.value || "false";
|
||||
field.value = (
|
||||
boolString[0].toLowerCase() === "t" ||
|
||||
boolString[0].toLowerCase() === "y" ||
|
||||
boolString[0] === "1"
|
||||
);
|
||||
} else if (field.type === "number") {
|
||||
field.value = Number(part.value as string || "0");
|
||||
if (isNaN(field.value)) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
|
||||
}
|
||||
} else if (field.type === "date") {
|
||||
field.value = part.value as string || "";
|
||||
const date = new Date(field.value);
|
||||
if (isNaN(date.getTime())) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
|
||||
}
|
||||
} else {
|
||||
field.value = part.value as string || "";
|
||||
}
|
||||
|
||||
initFields.push(field);
|
||||
});
|
||||
|
||||
if (initFields.length && !error) {
|
||||
fields.value = initFields;
|
||||
} else {
|
||||
initFieldsError();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
initializeFields();
|
||||
} catch (error) {
|
||||
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
||||
}
|
||||
|
||||
|
||||
const attrs = computed(() => {
|
||||
const baseColMaxWidth = 55;
|
||||
const attrs = {
|
||||
col: {
|
||||
class: "d-flex justify-center align-end field-col pa-1",
|
||||
},
|
||||
select: {
|
||||
textClass: "d-flex justify-center text-center",
|
||||
},
|
||||
fields: {
|
||||
icon: {
|
||||
cols: 1,
|
||||
style: "width: fit-content;",
|
||||
},
|
||||
leftParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
},
|
||||
logicalOperator: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
},
|
||||
fieldName: {
|
||||
cols: state.showAdvanced ? 2 : 3,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
},
|
||||
relationalOperator: {
|
||||
cols: 2,
|
||||
style: `min-width: ${baseColMaxWidth * 2}px;`,
|
||||
},
|
||||
fieldValue: {
|
||||
cols: state.showAdvanced ? 3 : 4,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
},
|
||||
rightParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
},
|
||||
fieldActions: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return attrs;
|
||||
})
|
||||
|
||||
return {
|
||||
Organizer,
|
||||
...toRefs(state),
|
||||
logOps,
|
||||
relOps,
|
||||
attrs,
|
||||
firstDayOfWeek,
|
||||
onDragEnd,
|
||||
// Fields
|
||||
fields,
|
||||
addField,
|
||||
setField,
|
||||
setLeftParenthesisValue,
|
||||
setRightParenthesisValue,
|
||||
setLogicalOperatorValue,
|
||||
setRelationalOperatorValue,
|
||||
setFieldValue,
|
||||
setFieldValues,
|
||||
setOrganizerValues,
|
||||
removeField,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
font-size: 1em;
|
||||
}
|
||||
</style>
|
|
@ -143,7 +143,9 @@ export default defineComponent({
|
|||
const typeMap = {
|
||||
"categories": "category.category",
|
||||
"tags": "tag.tag",
|
||||
"tools": "tool.tool"
|
||||
"tools": "tool.tool",
|
||||
"foods": "shopping-list.food",
|
||||
"households": "household.household",
|
||||
};
|
||||
return typeMap[props.itemType] || "";
|
||||
});
|
||||
|
|
|
@ -8,13 +8,12 @@
|
|||
deletable-chips
|
||||
item-text="name"
|
||||
multiple
|
||||
:prepend-inner-icon="selectorType === Organizer.Tool ? $globals.icons.potSteam :
|
||||
selectorType === Organizer.Category ? $globals.icons.categories :
|
||||
$globals.icons.tags"
|
||||
:prepend-inner-icon="icon"
|
||||
return-object
|
||||
v-bind="inputAttrs"
|
||||
auto-select-first
|
||||
:search-input.sync="searchInput"
|
||||
class="pa-0"
|
||||
@change="resetSearchInput"
|
||||
>
|
||||
<template #selection="data">
|
||||
|
@ -46,11 +45,11 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
||||
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
||||
import { RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||
import { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||
import { RecipeTool } from "~/lib/api/types/admin";
|
||||
import { useTagStore } from "~/composables/store/use-tag-store";
|
||||
import { useCategoryStore, useToolStore } from "~/composables/store";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
|
@ -58,7 +57,14 @@ export default defineComponent({
|
|||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool | string)[] | undefined,
|
||||
type: Array as () => (
|
||||
| HouseholdSummary
|
||||
| RecipeTag
|
||||
| RecipeCategory
|
||||
| RecipeTool
|
||||
| IngredientFood
|
||||
| string
|
||||
)[] | undefined,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
|
@ -80,6 +86,14 @@ export default defineComponent({
|
|||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, context) {
|
||||
|
@ -96,9 +110,13 @@ export default defineComponent({
|
|||
}
|
||||
});
|
||||
|
||||
const { i18n } = useContext();
|
||||
const { $globals, i18n } = useContext();
|
||||
|
||||
const label = computed(() => {
|
||||
if (!props.showLabel) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return i18n.t("tag.tags");
|
||||
|
@ -106,30 +124,57 @@ export default defineComponent({
|
|||
return i18n.t("category.categories");
|
||||
case Organizer.Tool:
|
||||
return i18n.t("tool.tools");
|
||||
case Organizer.Food:
|
||||
return i18n.t("general.foods");
|
||||
case Organizer.Household:
|
||||
return i18n.t("household.households");
|
||||
default:
|
||||
return i18n.t("general.organizer");
|
||||
}
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
if (!props.showIcon) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return $globals.icons.tags;
|
||||
case Organizer.Category:
|
||||
return $globals.icons.categories;
|
||||
case Organizer.Tool:
|
||||
return $globals.icons.tools;
|
||||
case Organizer.Food:
|
||||
return $globals.icons.foods;
|
||||
case Organizer.Household:
|
||||
return $globals.icons.household;
|
||||
default:
|
||||
return $globals.icons.tags;
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Store & Items Setup
|
||||
|
||||
const store = (() => {
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return useTagStore();
|
||||
case Organizer.Tool:
|
||||
return useToolStore();
|
||||
default:
|
||||
return useCategoryStore();
|
||||
}
|
||||
})();
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
};
|
||||
|
||||
const store = computed(() => {
|
||||
const { store } = storeMap[props.selectorType];
|
||||
return store.value;
|
||||
})
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.returnObject) {
|
||||
return store.store.value.map((item) => item.name);
|
||||
return store.value.map((item) => item.name);
|
||||
}
|
||||
return store.store.value;
|
||||
return store.value;
|
||||
});
|
||||
|
||||
function removeByIndex(index: number) {
|
||||
|
@ -140,7 +185,7 @@ export default defineComponent({
|
|||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
function appendCreated(item: RecipeTag | RecipeCategory | RecipeTool) {
|
||||
function appendCreated(item: any) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
@ -162,6 +207,7 @@ export default defineComponent({
|
|||
dialog,
|
||||
storeItem: items,
|
||||
label,
|
||||
icon,
|
||||
selected,
|
||||
removeByIndex,
|
||||
searchInput,
|
||||
|
@ -170,3 +216,10 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-autocomplete {
|
||||
/* This aligns the input with other standard input fields */
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
{{ $t("general.confirm") }}
|
||||
</BaseButton>
|
||||
<slot name="custom-card-action"></slot>
|
||||
<BaseButton v-if="$listeners.submit" type="submit" @click="submitEvent">
|
||||
<BaseButton v-if="$listeners.submit" type="submit" :disabled="submitDisabled" @click="submitEvent">
|
||||
{{ submitText }}
|
||||
<template v-if="submitIcon" #icon>
|
||||
{{ submitIcon }}
|
||||
|
@ -125,6 +125,10 @@ export default defineComponent({
|
|||
return this.$t("general.create");
|
||||
},
|
||||
},
|
||||
submitDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
keepOpen: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue