1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-05 05:25:26 +02:00

feat: Query Filter Builder for Cookbooks and Meal Plans (#4346)

This commit is contained in:
Michael Genson 2024-10-17 10:35:39 -05:00 committed by GitHub
parent 2a9a6fa5e6
commit b8e62ab8dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2043 additions and 440 deletions

View file

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

View file

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

View file

@ -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,
};
},
});

View 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>

View file

@ -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] || "";
});

View file

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

View file

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

View file

@ -51,6 +51,9 @@ export function useReadOnlyActions<T extends BoundT>(
}
async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
params.orderBy ??= "name";
params.orderDirection ??= "asc";
loading.value = true;
const { data } = await api.getAll(page, perPage, params);
@ -102,6 +105,9 @@ export function useStoreActions<T extends BoundT>(
}
async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
params.orderBy ??= "name";
params.orderDirection ??= "asc";
loading.value = true;
const { data } = await api.getAll(page, perPage, params);

View file

@ -103,6 +103,8 @@ export const useCookbooks = function () {
loading.value = true;
const { data } = await api.cookbooks.createOne({
name: i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((cookbookStore?.value?.length ?? 0) + 1)]) as string,
position: (cookbookStore?.value?.length ?? 0) + 1,
queryFilterString: "",
});
if (data && cookbookStore?.value) {
cookbookStore.value.push(data);

View file

@ -0,0 +1,318 @@
import { computed, useContext } from "@nuxtjs/composition-api";
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
import { LogicalOperator, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
export interface FieldLogicalOperator {
label: string;
value: LogicalOperator;
}
export interface FieldRelationalOperator {
label: string;
value: RelationalKeyword | RelationalOperator;
}
export interface OrganizerBase {
id: string;
slug: string;
name: string;
}
export type FieldType =
| "string"
| "number"
| "boolean"
| "date"
| RecipeOrganizer;
export type FieldValue =
| string
| number
| boolean
| Date
| Organizer;
export interface SelectableItem {
label: string;
value: FieldValue;
};
export interface FieldDefinition {
name: string;
label: string;
type: FieldType;
// only for select/organizer fields
fieldOptions?: SelectableItem[];
}
export interface Field extends FieldDefinition {
leftParenthesis?: string;
logicalOperator?: FieldLogicalOperator;
value: FieldValue;
relationalOperatorValue: FieldRelationalOperator;
relationalOperatorOptions: FieldRelationalOperator[];
rightParenthesis?: string;
// only for select/organizer fields
values: FieldValue[];
organizers: OrganizerBase[];
}
export function useQueryFilterBuilder() {
const { i18n } = useContext();
const logOps = computed<Record<LogicalOperator, FieldLogicalOperator>>(() => {
const AND = {
label: i18n.tc("query-filter.logical-operators.and"),
value: "AND",
} as FieldLogicalOperator;
const OR = {
label: i18n.tc("query-filter.logical-operators.or"),
value: "OR",
} as FieldLogicalOperator;
return {
AND,
OR,
};
});
const relOps = computed<Record<RelationalKeyword | RelationalOperator, FieldRelationalOperator>>(() => {
const EQ = {
label: i18n.tc("query-filter.relational-operators.equals"),
value: "=",
} as FieldRelationalOperator;
const NOT_EQ = {
label: i18n.tc("query-filter.relational-operators.does-not-equal"),
value: "<>",
} as FieldRelationalOperator;
const GT = {
label: i18n.tc("query-filter.relational-operators.is-greater-than"),
value: ">",
} as FieldRelationalOperator;
const GTE = {
label: i18n.tc("query-filter.relational-operators.is-greater-than-or-equal-to"),
value: ">=",
} as FieldRelationalOperator;
const LT = {
label: i18n.tc("query-filter.relational-operators.is-less-than"),
value: "<",
} as FieldRelationalOperator;
const LTE = {
label: i18n.tc("query-filter.relational-operators.is-less-than-or-equal-to"),
value: "<=",
} as FieldRelationalOperator;
const IS = {
label: i18n.tc("query-filter.relational-keywords.is"),
value: "IS",
} as FieldRelationalOperator;
const IS_NOT = {
label: i18n.tc("query-filter.relational-keywords.is-not"),
value: "IS NOT",
} as FieldRelationalOperator;
const IN = {
label: i18n.tc("query-filter.relational-keywords.is-one-of"),
value: "IN",
} as FieldRelationalOperator;
const NOT_IN = {
label: i18n.tc("query-filter.relational-keywords.is-not-one-of"),
value: "NOT IN",
} as FieldRelationalOperator;
const CONTAINS_ALL = {
label: i18n.tc("query-filter.relational-keywords.contains-all-of"),
value: "CONTAINS ALL",
} as FieldRelationalOperator;
const LIKE = {
label: i18n.tc("query-filter.relational-keywords.is-like"),
value: "LIKE",
} as FieldRelationalOperator;
const NOT_LIKE = {
label: i18n.tc("query-filter.relational-keywords.is-not-like"),
value: "NOT LIKE",
} as FieldRelationalOperator;
/* eslint-disable object-shorthand */
return {
"=": EQ,
"<>": NOT_EQ,
">": GT,
">=": GTE,
"<": LT,
"<=": LTE,
"IS": IS,
"IS NOT": IS_NOT,
"IN": IN,
"NOT IN": NOT_IN,
"CONTAINS ALL": CONTAINS_ALL,
"LIKE": LIKE,
"NOT LIKE": NOT_LIKE,
};
/* eslint-enable object-shorthand */
});
function isOrganizerType(type: FieldType): type is Organizer {
return (
type === Organizer.Category ||
type === Organizer.Tag ||
type === Organizer.Tool ||
type === Organizer.Food ||
type === Organizer.Household
);
};
function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field {
/* eslint-disable dot-notation */
const updatedField = {logicalOperator: logOps.value.AND, ...field} as Field;
let operatorOptions: FieldRelationalOperator[];
if (updatedField.fieldOptions?.length || isOrganizerType(updatedField.type)) {
operatorOptions = [
relOps.value["IN"],
relOps.value["NOT IN"],
relOps.value["CONTAINS ALL"],
];
} else {
switch (updatedField.type) {
case "string":
operatorOptions = [
relOps.value["="],
relOps.value["<>"],
relOps.value["LIKE"],
relOps.value["NOT LIKE"],
];
break;
case "number":
operatorOptions = [
relOps.value["="],
relOps.value["<>"],
relOps.value[">"],
relOps.value[">="],
relOps.value["<"],
relOps.value["<="],
];
break;
case "boolean":
operatorOptions = [relOps.value["="]];
break;
case "date":
operatorOptions = [
relOps.value["="],
relOps.value["<>"],
relOps.value[">"],
relOps.value[">="],
relOps.value["<"],
relOps.value["<="],
];
break;
default:
operatorOptions = [relOps.value["="], relOps.value["<>"]];
}
}
updatedField.relationalOperatorOptions = operatorOptions;
if (!operatorOptions.includes(updatedField.relationalOperatorValue)) {
updatedField.relationalOperatorValue = operatorOptions[0];
}
if (resetValue) {
updatedField.value = "";
updatedField.values = [];
updatedField.organizers = [];
} else {
updatedField.value = updatedField.value || "";
updatedField.values = updatedField.values || [];
updatedField.organizers = updatedField.organizers || [];
}
return updatedField;
/* eslint-enable dot-notation */
};
function buildQueryFilterString(fields: Field[], useParenthesis: boolean): string {
let isValid = true;
let lParenCounter = 0;
let rParenCounter = 0;
const parts: string[] = [];
fields.forEach((field, index) => {
if (index) {
if (!field.logicalOperator) {
field.logicalOperator = logOps.value.AND;
}
parts.push(field.logicalOperator.value);
}
if (field.leftParenthesis && useParenthesis) {
lParenCounter += field.leftParenthesis.length;
parts.push(field.leftParenthesis);
}
if (field.label) {
parts.push(field.name);
} else {
isValid = false;
}
if (field.relationalOperatorValue) {
parts.push(field.relationalOperatorValue.value);
} else if (field.type !== "boolean") {
isValid = false;
}
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
if (field.values?.length) {
let val: string;
if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) {
val = field.values.map((value) => `"${value.toString()}"`).join(",");
} else {
val = field.values.join(",");
}
parts.push(`[${val}]`);
} else {
isValid = false;
}
} else if (field.value) {
if (field.type === "string" || field.type === "date") {
parts.push(`"${field.value.toString()}"`);
} else {
parts.push(field.value.toString());
}
} else if (field.type === "boolean") {
parts.push("false");
} else {
isValid = false;
}
if (field.rightParenthesis && useParenthesis) {
rParenCounter += field.rightParenthesis.length;
parts.push(field.rightParenthesis);
}
});
if (lParenCounter !== rParenCounter) {
isValid = false;
}
return isValid ? parts.join(" ") : "";
}
return {
logOps,
relOps,
buildQueryFilterString,
getFieldFromFieldDef,
isOrganizerType,
};
}

View file

@ -212,7 +212,11 @@
"clipboard-copy-failure": "Failed to copy to the clipboard.",
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
"organizers": "Organizers",
"caution": "Caution"
"caution": "Caution",
"show-advanced": "Show Advanced",
"add-field": "Add Field",
"date-created": "Date Created",
"date-updated": "Date Updated"
},
"group": {
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
@ -351,7 +355,7 @@
"for-type-meal-types": "for {0} meal types",
"meal-plan-rules": "Meal Plan Rules",
"new-rule": "New Rule",
"meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the categories of the rules will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.",
"meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the rule filters will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.",
"new-rule-description": "When creating a new rule for a meal plan you can restrict the rule to be applicable for a specific day of the week and/or a specific type of meal. To apply a rule to all days or all meal types you can set the rule to \"Any\" which will apply it to all the possible values for the day and/or meal type.",
"recipe-rules": "Recipe Rules",
"applies-to-all-days": "Applies to all days",
@ -1319,7 +1323,7 @@
},
"cookbook": {
"cookbooks": "Cookbooks",
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes and tags. Creating a cookbook will add an entry to the side-bar and all the recipes with the tags and categories chosen will be displayed in the cookbook.",
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the side-bar and all the recipes with the filters chosen will be displayed in the cookbook.",
"public-cookbook": "Public Cookbook",
"public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.",
"filter-options": "Filter Options",
@ -1332,5 +1336,28 @@
"household-cookbook-name": "{0} Cookbook {1}",
"create-a-cookbook": "Create a Cookbook",
"cookbook": "Cookbook"
},
"query-filter": {
"logical-operators": {
"and": "AND",
"or": "OR"
},
"relational-operators": {
"equals": "equals",
"does-not-equal": "does not equal",
"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"
},
"relational-keywords": {
"is": "is",
"is-not": "is not",
"is-one-of": "is one of",
"is-not-one-of": "is not one of",
"contains-all-of": "contains all of",
"is-like": "is like",
"is-not-like": "is not like"
}
}
}

View file

@ -5,34 +5,17 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type LogicalOperator = "AND" | "OR";
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
export interface CreateCookBook {
name: string;
description?: string;
slug?: string | null;
position?: number;
public?: boolean;
categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
requireAllCategories?: boolean;
requireAllTags?: boolean;
requireAllTools?: boolean;
}
export interface CategoryBase {
name: string;
id: string;
slug: string;
}
export interface TagBase {
name: string;
id: string;
slug: string;
}
export interface RecipeTool {
id: string;
name: string;
slug: string;
onHand?: boolean;
queryFilterString: string;
}
export interface ReadCookBook {
name: string;
@ -40,15 +23,23 @@ export interface ReadCookBook {
slug?: string | null;
position?: number;
public?: boolean;
categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
requireAllCategories?: boolean;
requireAllTags?: boolean;
requireAllTools?: boolean;
queryFilterString: string;
groupId: string;
householdId: string;
id: string;
queryFilter: QueryFilterJSON;
}
export interface QueryFilterJSON {
parts?: QueryFilterJSONPart[];
}
export interface QueryFilterJSONPart {
leftParenthesis?: string | null;
rightParenthesis?: string | null;
logicalOperator?: LogicalOperator | null;
attributeName?: string | null;
relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | null;
[k: string]: unknown;
}
export interface RecipeCookBook {
name: string;
@ -56,15 +47,11 @@ export interface RecipeCookBook {
slug?: string | null;
position?: number;
public?: boolean;
categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
requireAllCategories?: boolean;
requireAllTags?: boolean;
requireAllTools?: boolean;
queryFilterString: string;
groupId: string;
householdId: string;
id: string;
queryFilter: QueryFilterJSON;
recipes: RecipeSummary[];
}
export interface RecipeSummary {
@ -104,18 +91,20 @@ export interface RecipeTag {
slug: string;
[k: string]: unknown;
}
export interface RecipeTool {
id: string;
name: string;
slug: string;
onHand?: boolean;
[k: string]: unknown;
}
export interface SaveCookBook {
name: string;
description?: string;
slug?: string | null;
position?: number;
public?: boolean;
categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
requireAllCategories?: boolean;
requireAllTags?: boolean;
requireAllTools?: boolean;
queryFilterString: string;
groupId: string;
householdId: string;
}
@ -125,12 +114,7 @@ export interface UpdateCookBook {
slug?: string | null;
position?: number;
public?: boolean;
categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
requireAllCategories?: boolean;
requireAllTags?: boolean;
requireAllTools?: boolean;
queryFilterString: string;
groupId: string;
householdId: string;
id: string;

View file

@ -8,6 +8,9 @@
export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side";
export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" | "unset";
export type PlanRulesType = "breakfast" | "lunch" | "dinner" | "side" | "unset";
export type LogicalOperator = "AND" | "OR";
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
export interface Category {
id: string;
@ -31,44 +34,36 @@ export interface ListItem {
quantity?: number;
checked?: boolean;
}
export interface PlanCategory {
id: string;
name: string;
slug: string;
}
export interface PlanHousehold {
id: string;
name: string;
slug: string;
}
export interface PlanRulesCreate {
day?: PlanRulesDay & string;
entryType?: PlanRulesType & string;
categories?: PlanCategory[];
tags?: PlanTag[];
households?: PlanHousehold[];
}
export interface PlanTag {
id: string;
name: string;
slug: string;
queryFilterString: string;
}
export interface PlanRulesOut {
day?: PlanRulesDay & string;
entryType?: PlanRulesType & string;
categories?: PlanCategory[];
tags?: PlanTag[];
households?: PlanHousehold[];
queryFilterString: string;
groupId: string;
householdId: string;
id: string;
queryFilter: QueryFilterJSON;
}
export interface QueryFilterJSON {
parts?: QueryFilterJSONPart[];
}
export interface QueryFilterJSONPart {
leftParenthesis?: string | null;
rightParenthesis?: string | null;
logicalOperator?: LogicalOperator | null;
attributeName?: string | null;
relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | null;
[k: string]: unknown;
}
export interface PlanRulesSave {
day?: PlanRulesDay & string;
entryType?: PlanRulesType & string;
categories?: PlanCategory[];
tags?: PlanTag[];
households?: PlanHousehold[];
queryFilterString: string;
groupId: string;
householdId: string;
}
@ -126,6 +121,7 @@ export interface RecipeTool {
name: string;
slug: string;
onHand?: boolean;
[k: string]: unknown;
}
export interface SavePlanEntry {
date: string;

View file

@ -24,10 +24,17 @@ export interface PaginationData<T> {
items: T[];
}
export type RecipeOrganizer = "categories" | "tags" | "tools";
export type RecipeOrganizer =
| "categories"
| "tags"
| "tools"
| "foods"
| "households";
export enum Organizer {
Category = "categories",
Tag = "tags",
Tool = "tools",
Food = "foods",
Household = "households",
}

View file

@ -7,6 +7,9 @@
export type OrderByNullPosition = "first" | "last";
export type OrderDirection = "asc" | "desc";
export type LogicalOperator = "AND" | "OR";
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
export interface ErrorResponse {
message: string;
@ -25,6 +28,17 @@ export interface PaginationQuery {
queryFilter?: string | null;
paginationSeed?: string | null;
}
export interface QueryFilterJSON {
parts?: QueryFilterJSONPart[];
}
export interface QueryFilterJSONPart {
leftParenthesis?: string | null;
rightParenthesis?: string | null;
logicalOperator?: LogicalOperator | null;
attributeName?: string | null;
relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | null;
}
export interface RecipeSearchQuery {
cookbook?: string | null;
requireAllCategories?: boolean;

View file

@ -14,7 +14,6 @@ export class MealPlanAPI extends BaseCRUDAPI<CreatePlanEntry, ReadPlanEntry, Upd
itemRoute = routes.mealplanId;
async setRandom(payload: CreateRandomEntry) {
console.log(payload);
return await this.requests.post<ReadPlanEntry>(routes.random, payload);
}
}

View file

@ -4,16 +4,19 @@
<BaseDialog
v-if="createTarget"
v-model="dialogStates.create"
:width="650"
width="100%"
max-width="1100px"
:icon="$globals.icons.pages"
:title="$t('cookbook.create-a-cookbook')"
:submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')"
:submit-disabled="!createTarget.queryFilterString"
@submit="actions.updateOne(createTarget)"
@cancel="actions.deleteOne(createTarget.id)"
@cancel="deleteCreateTarget()"
>
<v-card-text>
<CookbookEditor
:key="createTargetKey"
:cookbook=createTarget
:actions="actions"
/>
@ -36,7 +39,7 @@
<!-- Cookbook Page -->
<!-- Page Title -->
<v-container class="narrow-container">
<v-container class="px-12">
<BasePageTitle divider>
<template #header>
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
@ -51,7 +54,7 @@
<!-- Cookbook List -->
<v-expansion-panels class="mt-2">
<draggable v-model="cookbooks" handle=".handle" style="width: 100%" @change="actions.updateOrder()">
<v-expansion-panel v-for="(cookbook, index) in cookbooks" :key="index" class="my-2 left-border rounded">
<v-expansion-panel v-for="cookbook in cookbooks" :key="cookbook.id" class="my-2 left-border rounded">
<v-expansion-panel-header disable-icon-rotate class="headline">
<div class="d-flex align-center">
<v-icon large left>
@ -84,6 +87,7 @@
icon: $globals.icons.save,
text: $tc('general.save'),
event: 'save',
disabled: !cookbook.queryFilterString
},
]"
@delete="deleteEventHandler(cookbook)"
@ -99,7 +103,7 @@
<script lang="ts">
import { defineComponent, reactive, ref } from "@nuxtjs/composition-api";
import { defineComponent, onBeforeUnmount, onMounted, reactive, ref } from "@nuxtjs/composition-api";
import draggable from "vuedraggable";
import { useCookbooks } from "@/composables/use-group-cookbooks";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
@ -116,10 +120,12 @@ export default defineComponent({
const { cookbooks, actions } = useCookbooks();
// create
const createTargetKey = ref(0);
const createTarget = ref<ReadCookBook | null>(null);
async function createCookbook() {
await actions.createOne().then((cookbook) => {
createTarget.value = cookbook as ReadCookBook;
createTargetKey.value++;
});
dialogStates.create = true;
}
@ -138,11 +144,37 @@ export default defineComponent({
dialogStates.delete = false;
deleteTarget.value = null;
}
function deleteCreateTarget() {
if (!createTarget.value?.id) {
return;
}
actions.deleteOne(createTarget.value.id);
dialogStates.create = false;
createTarget.value = null;
}
function handleUnmount() {
if(!createTarget.value?.id || createTarget.value.queryFilterString) {
return;
}
deleteCreateTarget();
}
onMounted(() => {
window.addEventListener("beforeunload", handleUnmount);
});
onBeforeUnmount(() => {
handleUnmount();
window.removeEventListener("beforeunload", handleUnmount);
});
return {
cookbooks,
actions,
dialogStates,
// create
createTargetKey,
createTarget,
createCookbook,
@ -150,6 +182,7 @@ export default defineComponent({
deleteTarget,
deleteEventHandler,
deleteCookbook,
deleteCreateTarget,
};
},
head() {

View file

@ -15,16 +15,15 @@
{{ $t('meal-plan.new-rule-description') }}
<GroupMealPlanRuleForm
:key="createDataFormKey"
class="mt-2"
:day.sync="createData.day"
:entry-type.sync="createData.entryType"
:categories.sync="createData.categories"
:tags.sync="createData.tags"
:households.sync="createData.households"
:query-filter-string.sync="createData.queryFilterString"
/>
</v-card-text>
<v-card-actions class="justify-end">
<BaseButton create @click="createRule" />
<BaseButton create :disabled="!createData.queryFilterString" @click="createRule" />
</v-card-actions>
</v-card>
@ -117,12 +116,11 @@
<GroupMealPlanRuleForm
:day.sync="allRules[idx].day"
:entry-type.sync="allRules[idx].entryType"
:categories.sync="allRules[idx].categories"
:tags.sync="allRules[idx].tags"
:households.sync="allRules[idx].households"
:query-filter-string.sync="allRules[idx].queryFilterString"
:query-filter="allRules[idx].queryFilter"
/>
<div class="d-flex justify-end">
<BaseButton update @click="updateRule(rule)" />
<BaseButton update :disabled="!allRules[idx].queryFilterString" @click="updateRule(rule)" />
</div>
</template>
</v-card-text>
@ -181,12 +179,11 @@ export default defineComponent({
// ======================================================
// Creating Rules
const createDataFormKey = ref(0);
const createData = ref<PlanRulesCreate>({
entryType: "unset",
day: "unset",
categories: [],
tags: [],
households: [],
queryFilterString: "",
});
async function createRule() {
@ -196,10 +193,9 @@ export default defineComponent({
createData.value = {
entryType: "unset",
day: "unset",
categories: [],
tags: [],
households: [],
queryFilterString: "",
};
createDataFormKey.value++;
}
}
@ -220,6 +216,7 @@ export default defineComponent({
return {
allRules,
createDataFormKey,
createData,
createRule,
deleteRule,