Merge branch 'mealie-next' into fix/admin-users-couldnt-delete-other-household-recipes

This commit is contained in:
Michael Genson 2025-07-29 17:22:50 -05:00 committed by GitHub
commit 4f11a6910e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 662 additions and 731 deletions

View file

@ -44,78 +44,54 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import type { ReadCookBook } from "~/lib/api/types/cookbook";
import { Organizer } from "~/lib/api/types/non-generated"; import { Organizer } from "~/lib/api/types/non-generated";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue"; import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import type { FieldDefinition } from "~/composables/use-query-filter-builder"; import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
export default defineNuxtComponent({ const modelValue = defineModel<ReadCookBook>({ required: true });
components: { QueryFilterBuilder }, const i18n = useI18n();
props: { const cookbook = toRef(modelValue);
modelValue: { function handleInput(value: string | undefined) {
type: Object as () => ReadCookBook, cookbook.value.queryFilterString = value || "";
required: true, }
},
actions: { const fieldDefs: FieldDefinition[] = [
type: Object as () => any, {
required: true, name: "recipe_category.id",
}, label: i18n.t("category.categories"),
type: Organizer.Category,
}, },
emits: ["update:modelValue"], {
setup(props, { emit }) { name: "tags.id",
const i18n = useI18n(); label: i18n.t("tag.tags"),
type: Organizer.Tag,
const cookbook = toRef(() => props.modelValue);
function handleInput(value: string | undefined) {
cookbook.value.queryFilterString = value || "";
emit("update:modelValue", cookbook.value);
}
const fieldDefs: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
type: Organizer.Category,
},
{
name: "tags.id",
label: i18n.t("tag.tags"),
type: Organizer.Tag,
},
{
name: "recipe_ingredient.food.id",
label: i18n.t("recipe.ingredients"),
type: Organizer.Food,
},
{
name: "tools.id",
label: i18n.t("tool.tools"),
type: Organizer.Tool,
},
{
name: "household_id",
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "created_at",
label: i18n.t("general.date-created"),
type: "date",
},
{
name: "updated_at",
label: i18n.t("general.date-updated"),
type: "date",
},
];
return {
cookbook,
handleInput,
fieldDefs,
};
}, },
}); {
name: "recipe_ingredient.food.id",
label: i18n.t("recipe.ingredients"),
type: Organizer.Food,
},
{
name: "tools.id",
label: i18n.t("tool.tools"),
type: Organizer.Tool,
},
{
name: "household_id",
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "created_at",
label: i18n.t("general.date-created"),
type: "date",
},
{
name: "updated_at",
label: i18n.t("general.date-updated"),
type: "date",
},
];
</script> </script>

View file

@ -17,7 +17,6 @@
<v-card-text> <v-card-text>
<CookbookEditor <CookbookEditor
v-model="editTarget" v-model="editTarget"
:actions="actions"
/> />
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
@ -65,7 +64,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbookStore } from "~/composables/store/use-cookbook-store"; import { useCookbookStore } from "~/composables/store/use-cookbook-store";
@ -74,81 +73,58 @@ import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { RecipeCookBook } from "~/lib/api/types/cookbook"; import type { RecipeCookBook } from "~/lib/api/types/cookbook";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue"; import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
export default defineNuxtComponent({ const $auth = useMealieAuth();
components: { RecipeCardSection, CookbookEditor }, const { isOwnGroup } = useLoggedInState();
setup() {
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value); const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.params.slug as string; const slug = route.params.slug as string;
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value); const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
const { actions } = useCookbookStore(); const { actions } = useCookbookStore();
const router = useRouter(); const router = useRouter();
const tab = ref(null); const book = getOne(slug);
const book = getOne(slug);
const isOwnHousehold = computed(() => { const isOwnHousehold = computed(() => {
if (!($auth.user.value && book.value?.householdId)) { if (!($auth.user.value && book.value?.householdId)) {
return false; return false;
} }
return $auth.user.value.householdId === book.value.householdId; return $auth.user.value.householdId === book.value.householdId;
}); });
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value); const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
const dialogStates = reactive({ const dialogStates = reactive({
edit: false, edit: false,
}); });
const editTarget = ref<RecipeCookBook | null>(null); const editTarget = ref<RecipeCookBook | null>(null);
function handleEditCookbook() { function handleEditCookbook() {
dialogStates.edit = true; dialogStates.edit = true;
editTarget.value = book.value; editTarget.value = book.value;
} }
async function editCookbook() { async function editCookbook() {
if (!editTarget.value) { if (!editTarget.value) {
return; return;
} }
const response = await actions.updateOne(editTarget.value); const response = await actions.updateOne(editTarget.value);
if (response?.slug && book.value?.slug !== response?.slug) { if (response?.slug && book.value?.slug !== response?.slug) {
// if name changed, redirect to new slug // if name changed, redirect to new slug
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`); router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
} }
else { else {
// otherwise reload the page, since the recipe criteria changed // otherwise reload the page, since the recipe criteria changed
router.go(0); router.go(0);
} }
dialogStates.edit = false; dialogStates.edit = false;
editTarget.value = null; editTarget.value = null;
} }
useSeoMeta({ useSeoMeta({
title: book?.value?.name || "Cookbook", title: book?.value?.name || "Cookbook",
});
return {
book,
slug,
tab,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
canEdit,
dialogStates,
editTarget,
handleEditCookbook,
editCookbook,
actions,
};
},
}); });
</script> </script>

View file

@ -163,14 +163,14 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-text-field <v-text-field
v-model="field.value" v-model="field.value"
persistent-hint persistent-hint
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
variant="underlined" variant="underlined"
color="primary" color="primary"
v-bind="props" v-bind="activatorProps"
readonly readonly
/> />
</template> </template>
@ -184,53 +184,48 @@
</v-menu> </v-menu>
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category" v-else-if="field.type === Organizer.Category"
:model-value="field.organizers" v-model="field.organizers"
:selector-type="Organizer.Category" :selector-type="Organizer.Category"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag" v-else-if="field.type === Organizer.Tag"
:model-value="field.organizers" v-model="field.organizers"
:selector-type="Organizer.Tag" :selector-type="Organizer.Tag"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool" v-else-if="field.type === Organizer.Tool"
:model-value="field.organizers" v-model="field.organizers"
:selector-type="Organizer.Tool" :selector-type="Organizer.Tool"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food" v-else-if="field.type === Organizer.Food"
:model-value="field.organizers" v-model="field.organizers"
:selector-type="Organizer.Food" :selector-type="Organizer.Food"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household" v-else-if="field.type === Organizer.Household"
:model-value="field.organizers" v-model="field.organizers"
:selector-type="Organizer.Household" :selector-type="Organizer.Household"
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined" variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
/> />
</v-col> </v-col>
<!-- right parenthesis --> <!-- right parenthesis -->
@ -297,7 +292,7 @@
</v-card> </v-card>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus"; import { VueDraggable } from "vue-draggable-plus";
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
@ -307,365 +302,338 @@ import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalK
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/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";
export default defineNuxtComponent({ const props = defineProps({
components: { fieldDefs: {
VueDraggable, type: Array as () => FieldDefinition[],
RecipeOrganizerSelector, required: true,
}, },
props: { initialQueryFilter: {
fieldDefs: { type: Object as () => QueryFilterJSON | null,
type: Array as () => FieldDefinition[], default: null,
required: true,
},
initialQueryFilter: {
type: Object as () => QueryFilterJSON | null,
default: null,
},
}, },
emits: ["input", "inputJSON"], });
setup(props, context) {
const { household } = useHouseholdSelf();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
const firstDayOfWeek = computed(() => { const emit = defineEmits<{
return household.value?.preferences?.firstDayOfWeek || 0; (event: "input", value: string | undefined): void;
}); (event: "inputJSON", value: QueryFilterJSON | undefined): void;
}>();
const state = reactive({ const { household } = useHouseholdSelf();
showAdvanced: false, const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
qfValid: false,
datePickers: [] as boolean[],
drag: false,
});
const storeMap = { const firstDayOfWeek = computed(() => {
[Organizer.Category]: useCategoryStore(), return household.value?.preferences?.firstDayOfWeek || 0;
[Organizer.Tag]: useTagStore(), });
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
};
function onDragEnd(event: any) { const state = reactive({
state.drag = false; showAdvanced: false,
qfValid: false,
datePickers: [] as boolean[],
drag: false,
});
const { showAdvanced, datePickers, drag } = toRefs(state);
const oldIndex: number = event.oldIndex; const storeMap = {
const newIndex: number = event.newIndex; [Organizer.Category]: useCategoryStore(),
state.datePickers[oldIndex] = false; [Organizer.Tag]: useTagStore(),
state.datePickers[newIndex] = false; [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;
}
// add id to fields to prevent reactivity issues
type FieldWithId = Field & { id: number };
const fields = ref<FieldWithId[]>([]);
const uid = ref(1); // init uid to pass to fields
function useUid() {
return uid.value++;
}
function addField(field: FieldDefinition) {
fields.value.push({
...getFieldFromFieldDef(field),
id: useUid(),
});
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[index] = {
...getFieldFromFieldDef(updatedField, resetValue),
id: fields.value[index].id, // keep the id
};
}
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value[index].leftParenthesis = value;
}
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value[index].rightParenthesis = value;
}
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
if (!value) {
value = logOps.value.AND.value;
}
fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
}
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
fields.value[index].relationalOperatorValue = relOps.value[value];
}
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
state.datePickers[index] = false;
fields.value[index].value = value;
}
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
fields.value[index].values = values;
}
function removeField(index: number) {
fields.value.splice(index, 1);
state.datePickers.splice(index, 1);
}
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
/* newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; // recursive!!!
}); */
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
if (qf) {
console.debug(`Set query filter: ${qf}`);
}
state.qfValid = !!qf;
emit("input", qf || undefined);
emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
}, 500);
watch(fields, fieldsUpdater, { deep: true });
async function hydrateOrganizers(field: FieldWithId, _index: number) {
if (!field.values?.length || !isOrganizerType(field.type)) {
return;
}
const { store, actions } = storeMap[field.type];
if (!store.value.length) {
await actions.refresh();
}
const organizers = field.values.map((value) => {
const organizer = store.value.find(item => item?.id?.toString() === value);
if (!organizer) {
console.error(`Could not find organizer with id ${value}`);
return undefined;
}
return organizer;
});
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
return field;
}
function initFieldsError(error = "") {
if (error) {
console.error(error);
}
fields.value = [];
if (props.fieldDefs.length) {
addField(props.fieldDefs[0]);
}
}
async function initializeFields() {
if (!props.initialQueryFilter?.parts?.length) {
return initFieldsError();
}
const initFields: FieldWithId[] = [];
let error = false;
for (const [index, part] of props.initialQueryFilter.parts.entries()) {
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
if (!fieldDef) {
error = true;
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
} }
// add id to fields to prevent reactivity issues const field: FieldWithId = {
type FieldWithId = Field & { id: number }; ...getFieldFromFieldDef(fieldDef),
const fields = ref<FieldWithId[]>([]); id: useUid(),
};
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;
const uid = ref(1); // init uid to pass to fields if (field.leftParenthesis || field.rightParenthesis) {
function useUid() { state.showAdvanced = true;
return uid.value++; }
}
function addField(field: FieldDefinition) {
fields.value.push({
...getFieldFromFieldDef(field),
id: useUid(),
});
state.datePickers.push(false);
};
function setField(index: number, fieldLabel: string) { if (field.fieldOptions?.length || isOrganizerType(field.type)) {
state.datePickers[index] = false; if (typeof part.value === "string") {
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel); field.values = part.value ? [part.value] : [];
if (!fieldDef) { }
return; else {
} field.values = part.value || [];
}
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions); if (isOrganizerType(field.type)) {
const updatedField = { ...fields.value[index], ...fieldDef }; await 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 || "";
}
// we have to set this explicitly since it might be undefined initFields.push(field);
updatedField.fieldOptions = fieldDef.fieldOptions; }
fields.value[index] = { if (initFields.length && !error) {
...getFieldFromFieldDef(updatedField, resetValue), fields.value = initFields;
id: fields.value[index].id, // keep the id }
}; else {
} initFieldsError();
}
}
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) { onMounted(async () => {
fields.value[index].leftParenthesis = value; try {
} await initializeFields();
}
catch (error) {
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
}
});
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) { function buildQueryFilterJSON(): QueryFilterJSON {
fields.value[index].rightParenthesis = value; const parts = fields.value.map((field) => {
} const part: QueryFilterJSONPart = {
attributeName: field.name,
leftParenthesis: field.leftParenthesis,
rightParenthesis: field.rightParenthesis,
logicalOperator: field.logicalOperator?.value,
relationalOperator: field.relationalOperatorValue?.value,
};
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) { if (field.fieldOptions?.length || isOrganizerType(field.type)) {
if (!value) { part.value = field.values.map(value => value.toString());
value = logOps.value.AND.value; }
} else if (field.type === "boolean") {
part.value = field.value ? "true" : "false";
}
else {
part.value = (field.value || "").toString();
}
fields.value[index].logicalOperator = value ? logOps.value[value] : undefined; return part;
} });
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) { const qfJSON = { parts } as QueryFilterJSON;
fields.value[index].relationalOperatorValue = relOps.value[value]; console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
} return qfJSON;
}
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) { const config = computed(() => {
state.datePickers[index] = false; const baseColMaxWidth = 55;
fields.value[index].value = value; return {
} col: {
class: "d-flex justify-center align-end field-col pa-1",
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) { },
fields.value[index].values = values; select: {
} textClass: "d-flex justify-center text-center",
},
function setOrganizerValues(field: FieldWithId, index: number, values: OrganizerBase[]) { items: {
setFieldValues(field, index, values.map(value => value.id.toString())); icon: {
fields.value[index].organizers = values; cols: 1,
} style: "width: fit-content;",
},
function removeField(index: number) { leftParens: {
fields.value.splice(index, 1); cols: state.showAdvanced ? 1 : 0,
state.datePickers.splice(index, 1); style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
}; },
logicalOperator: {
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => { cols: 1,
/* newFields.forEach((field, index) => { style: `min-width: ${baseColMaxWidth}px;`,
const updatedField = getFieldFromFieldDef(field); },
fields.value[index] = updatedField; // recursive!!! fieldName: {
}); */ cols: state.showAdvanced ? 2 : 3,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
const qf = buildQueryFilterString(fields.value, state.showAdvanced); },
if (qf) { relationalOperator: {
console.debug(`Set query filter: ${qf}`); cols: 2,
} style: `min-width: ${baseColMaxWidth * 2}px;`,
state.qfValid = !!qf; },
fieldValue: {
context.emit("input", qf || undefined); cols: state.showAdvanced ? 3 : 4,
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined); style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
}, 500); },
rightParens: {
watch(fields, fieldsUpdater, { deep: true }); cols: state.showAdvanced ? 1 : 0,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
async function hydrateOrganizers(field: FieldWithId, index: number) { },
if (!field.values?.length || !isOrganizerType(field.type)) { fieldActions: {
return; cols: 1,
} style: `min-width: ${baseColMaxWidth}px;`,
},
field.organizers = []; },
};
const { store, actions } = storeMap[field.type];
if (!store.value.length) {
await actions.refresh();
}
const organizers = field.values.map((value) => {
const organizer = store.value.find(item => item?.id?.toString() === value);
if (!organizer) {
console.error(`Could not find organizer with id ${value}`);
return undefined;
}
return organizer;
});
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: FieldWithId[] = [];
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: FieldWithId = {
...getFieldFromFieldDef(fieldDef),
id: useUid(),
};
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()}`);
}
function buildQueryFilterJSON(): QueryFilterJSON {
const parts = fields.value.map((field) => {
const part: QueryFilterJSONPart = {
attributeName: field.name,
leftParenthesis: field.leftParenthesis,
rightParenthesis: field.rightParenthesis,
logicalOperator: field.logicalOperator?.value,
relationalOperator: field.relationalOperatorValue?.value,
};
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
part.value = field.values.map(value => value.toString());
}
else if (field.type === "boolean") {
part.value = field.value ? "true" : "false";
}
else {
part.value = (field.value || "").toString();
}
return part;
});
const qfJSON = { parts } as QueryFilterJSON;
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
return qfJSON;
}
const config = computed(() => {
const baseColMaxWidth = 55;
return {
col: {
class: "d-flex justify-center align-end field-col pa-1",
},
select: {
textClass: "d-flex justify-center text-center",
},
items: {
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 {
Organizer,
...toRefs(state),
logOps,
relOps,
config,
firstDayOfWeek,
onDragEnd,
// Fields
fields,
addField,
setField,
setLeftParenthesisValue,
setRightParenthesisValue,
setLogicalOperatorValue,
setRelationalOperatorValue,
setFieldValue,
setFieldValues,
setOrganizerValues,
removeField,
};
},
}); });
</script> </script>

View file

@ -17,7 +17,7 @@
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always /> <RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" /> <RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
<div v-if="loggedIn"> <div v-if="loggedIn">
<v-tooltip v-if="canEdit" bottom color="info"> <v-tooltip v-if="canEdit" location="bottom" color="info">
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn <v-btn
icon icon

View file

@ -15,7 +15,7 @@
> >
<template #prepend> <template #prepend>
<div class="ma-auto"> <div class="ma-auto">
<v-tooltip bottom> <v-tooltip location="bottom">
<template #activator="{ props: tooltipProps }"> <template #activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps"> <v-icon v-bind="tooltipProps">
{{ getIconDefinition(item.icon).icon }} {{ getIconDefinition(item.icon).icon }}

View file

@ -63,6 +63,22 @@
clearable clearable
@keyup.enter="handleUnitEnter" @keyup.enter="handleUnitEnter"
> >
<template #prepend>
<v-tooltip v-if="unitError" location="bottom">
<template #activator="{ props: unitTooltipProps }">
<v-icon
v-bind="unitTooltipProps"
class="ml-2 mr-n3 opacity-100"
color="primary"
>
{{ $globals.icons.alert }}
</v-icon>
</template>
<span v-if="unitErrorTooltip">
{{ unitErrorTooltip }}
</span>
</v-tooltip>
</template>
<template #no-data> <template #no-data>
<div class="caption text-center pb-2"> <div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }} {{ $t("recipe.press-enter-to-create") }}
@ -104,6 +120,22 @@
clearable clearable
@keyup.enter="handleFoodEnter" @keyup.enter="handleFoodEnter"
> >
<template #prepend>
<v-tooltip v-if="foodError" location="bottom">
<template #activator="{ props: foodTooltipProps }">
<v-icon
v-bind="foodTooltipProps"
class="ml-2 mr-n3 opacity-100"
color="primary"
>
{{ $globals.icons.alert }}
</v-icon>
</template>
<span v-if="foodErrorTooltip">
{{ foodErrorTooltip }}
</span>
</v-tooltip>
</template>
<template #no-data> <template #no-data>
<div class="caption text-center pb-2"> <div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }} {{ $t("recipe.press-enter-to-create") }}
@ -153,7 +185,6 @@
@toggle-original="toggleOriginalText" @toggle-original="toggleOriginalText"
@insert-above="$emit('insert-above')" @insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')" @insert-below="$emit('insert-below')"
@insert-ingredient="$emit('insert-ingredient')"
@delete="$emit('delete')" @delete="$emit('delete')"
/> />
</div> </div>
@ -184,22 +215,33 @@ import type { RecipeIngredient } from "~/lib/api/types/recipe";
// defineModel replaces modelValue prop // defineModel replaces modelValue prop
const model = defineModel<RecipeIngredient>({ required: true }); const model = defineModel<RecipeIngredient>({ required: true });
const props = defineProps({ defineProps({
disableAmount: { disableAmount: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
allowInsertIngredient: { unitError: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
unitErrorTooltip: {
type: String,
default: "",
},
foodError: {
type: Boolean,
default: false,
},
foodErrorTooltip: {
type: String,
default: "",
},
}); });
defineEmits([ defineEmits([
"clickIngredientField", "clickIngredientField",
"insert-above", "insert-above",
"insert-below", "insert-below",
"insert-ingredient",
"delete", "delete",
]); ]);
@ -228,13 +270,6 @@ const contextMenuOptions = computed(() => {
}, },
]; ];
if (props.allowInsertIngredient) {
options.push({
text: i18n.t("recipe.insert-ingredient"),
event: "insert-ingredient",
});
}
if (model.value.originalText) { if (model.value.originalText) {
options.push({ options.push({
text: i18n.t("recipe.see-original-text"), text: i18n.t("recipe.see-original-text"),

View file

@ -86,7 +86,7 @@
<div> <div>
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap"> <div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger"> <v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger">
<v-tooltip bottom> <v-tooltip location="bottom">
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn <v-btn
rounded rounded

View file

@ -3,7 +3,7 @@
v-model="selected" v-model="selected"
v-bind="inputAttrs" v-bind="inputAttrs"
v-model:search="searchInput" v-model:search="searchInput"
:items="storeItem" :items="items"
:label="label" :label="label"
chips chips
closable-chips closable-chips
@ -46,180 +46,138 @@
</v-autocomplete> </v-autocomplete>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe"; import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
import type { RecipeTool } from "~/lib/api/types/admin"; import type { RecipeTool } from "~/lib/api/types/admin";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated"; import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import type { HouseholdSummary } from "~/lib/api/types/household"; import type { HouseholdSummary } from "~/lib/api/types/household";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
export default defineNuxtComponent({ interface Props {
props: { selectorType: RecipeOrganizer;
modelValue: { inputAttrs?: Record<string, any>;
type: Array as () => ( returnObject?: boolean;
| HouseholdSummary showAdd?: boolean;
| RecipeTag showLabel?: boolean;
| RecipeCategory showIcon?: boolean;
| RecipeTool variant?: "filled" | "underlined" | "outlined" | "plain" | "solo" | "solo-inverted" | "solo-filled";
| IngredientFood }
| string
)[] | undefined,
required: true,
},
/**
* The type of organizer to use.
*/
selectorType: {
type: String as () => RecipeOrganizer,
required: true,
},
inputAttrs: {
type: Object as () => Record<string, any>,
default: () => ({}),
},
returnObject: {
type: Boolean,
default: true,
},
showAdd: {
type: Boolean,
default: true,
},
showLabel: {
type: Boolean,
default: true,
},
showIcon: {
type: Boolean,
default: true,
},
variant: {
type: String as () => "filled" | "underlined" | "outlined" | "plain" | "solo" | "solo-inverted" | "solo-filled",
default: "outlined",
},
},
emits: ["update:modelValue"],
setup(props, context) { const props = withDefaults(defineProps<Props>(), {
const selected = computed({ inputAttrs: () => ({}),
get: () => props.modelValue, returnObject: true,
set: (val) => { showAdd: true,
context.emit("update:modelValue", val); showLabel: true,
}, showIcon: true,
}); variant: "outlined",
onMounted(() => {
if (selected.value === undefined) {
selected.value = [];
}
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const label = computed(() => {
if (!props.showLabel) {
return "";
}
switch (props.selectorType) {
case Organizer.Tag:
return i18n.t("tag.tags");
case Organizer.Category:
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 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.value.map(item => item.name);
}
return store.value;
});
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
function appendCreated(item: any) {
if (selected.value === undefined) {
return;
}
selected.value = [...selected.value, item];
}
const dialog = ref(false);
const searchInput = ref("");
function resetSearchInput() {
searchInput.value = "";
}
return {
Organizer,
appendCreated,
dialog,
storeItem: items,
label,
icon,
selected,
removeByIndex,
searchInput,
resetSearchInput,
};
},
}); });
const selected = defineModel<(
| HouseholdSummary
| RecipeTag
| RecipeCategory
| RecipeTool
| IngredientFood
| string
)[] | undefined>({ required: true });
onMounted(() => {
if (selected.value === undefined) {
selected.value = [];
}
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const label = computed(() => {
if (!props.showLabel) {
return "";
}
switch (props.selectorType) {
case Organizer.Tag:
return i18n.t("tag.tags");
case Organizer.Category:
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 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.value.map(item => item.name);
}
return store.value;
});
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
function appendCreated(item: any) {
if (selected.value === undefined) {
return;
}
selected.value = [...selected.value, item];
}
const dialog = ref(false);
const searchInput = ref("");
function resetSearchInput() {
searchInput.value = "";
}
</script> </script>
<style scoped> <style scoped>

View file

@ -35,7 +35,7 @@
> >
<RecipeYield <RecipeYield
:yield-quantity="recipe.recipeYieldQuantity" :yield-quantity="recipe.recipeYieldQuantity"
:yield="recipe.recipeYield" :yield-text="recipe.recipeYield"
:scale="recipeScale" :scale="recipeScale"
class="mb-4" class="mb-4"
/> />

View file

@ -42,7 +42,7 @@
/> />
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3"> <div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
<v-tooltip <v-tooltip
top location="top"
color="accent" color="accent"
> >
<template #activator="{ props }"> <template #activator="{ props }">

View file

@ -222,7 +222,7 @@ export default defineNuxtComponent({
const servingsDisplay = computed(() => { const servingsDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale); const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
return scaledAmountDisplay return scaledAmountDisplay || props.recipe.recipeYield
? i18n.t("recipe.yields-amount-with-text", { ? i18n.t("recipe.yields-amount-with-text", {
amount: scaledAmountDisplay, amount: scaledAmountDisplay,
text: props.recipe.recipeYield, text: props.recipe.recipeYield,

View file

@ -14,7 +14,7 @@
<v-tooltip <v-tooltip
v-if="canEditScale" v-if="canEditScale"
size="small" size="small"
top location="top"
color="secondary-darken-1" color="secondary-darken-1"
> >
<template #activator="{ props: tooltipProps }"> <template #activator="{ props: tooltipProps }">
@ -74,7 +74,7 @@
@update:model-value="recalculateScale(parseFloat($event) || 0)" @update:model-value="recalculateScale(parseFloat($event) || 0)"
/> />
<v-tooltip <v-tooltip
end location="end"
color="secondary-darken-1" color="secondary-darken-1"
> >
<template #activator="{ props }"> <template #activator="{ props }">

View file

@ -1,6 +1,6 @@
<template> <template>
<v-tooltip <v-tooltip
bottom location="bottom"
nudge-right="50" nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'" :color="buttonStyle ? 'info' : 'secondary'"
> >

View file

@ -1,6 +1,6 @@
<template> <template>
<div <div
v-if="scaledAmount" v-if="yieldDisplay"
class="d-flex align-center" class="d-flex align-center"
> >
<v-row <v-row
@ -18,7 +18,7 @@
<p class="my-0 opacity-80"> <p class="my-0 opacity-80">
<span class="font-weight-bold">{{ $t("recipe.yield") }}</span><br> <span class="font-weight-bold">{{ $t("recipe.yield") }}</span><br>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="scaledAmount" /> {{ text }} <span v-html="yieldDisplay" />
</p> </p>
</v-row> </v-row>
</div> </div>
@ -34,7 +34,7 @@ export default defineNuxtComponent({
type: Number, type: Number,
default: 0, default: 0,
}, },
yield: { yieldText: {
type: String, type: String,
default: "", default: "",
}, },
@ -55,15 +55,24 @@ export default defineNuxtComponent({
}); });
} }
const scaledAmount = computed(() => { const yieldDisplay = computed<string>(() => {
const components: string[] = [];
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale); const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
return scaledAmountDisplay; if (scaledAmountDisplay) {
components.push(scaledAmountDisplay);
}
const text = props.yieldText;
if (text) {
components.push(text);
}
return sanitizeHTML(components.join(" "));
}); });
const text = sanitizeHTML(props.yield);
return { return {
scaledAmount, yieldDisplay,
text,
}; };
}, },
}); });

View file

@ -59,7 +59,7 @@
open-delay="200" open-delay="200"
transition="slide-x-reverse-transition" transition="slide-x-reverse-transition"
density="compact" density="compact"
right location="end"
content-class="text-caption" content-class="text-caption"
> >
<template #activator="{ props: tooltipProps }"> <template #activator="{ props: tooltipProps }">

View file

@ -2,7 +2,7 @@
<v-tooltip <v-tooltip
v-if="userId" v-if="userId"
:disabled="!user || !tooltip" :disabled="!user || !tooltip"
right location="end"
> >
<template #activator="{ props }"> <template #activator="{ props }">
<v-avatar <v-avatar

View file

@ -2,7 +2,7 @@
<v-tooltip <v-tooltip
ref="copyToolTip" ref="copyToolTip"
v-model="show" v-model="show"
top location="top"
:open-on-hover="false" :open-on-hover="false"
:open-on-click="true" :open-on-click="true"
close-delay="500" close-delay="500"

View file

@ -48,7 +48,7 @@
open-delay="200" open-delay="200"
transition="slide-y-reverse-transition" transition="slide-y-reverse-transition"
density="compact" density="compact"
bottom location="bottom"
content-class="text-caption" content-class="text-caption"
> >
<template #activator="{ props }"> <template #activator="{ props }">

View file

@ -663,6 +663,8 @@
"no-unit": "No unit", "no-unit": "No unit",
"missing-unit": "Create missing unit: {unit}", "missing-unit": "Create missing unit: {unit}",
"missing-food": "Create missing food: {food}", "missing-food": "Create missing food: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "No Food" "no-food": "No Food"
}, },
"reset-servings-count": "Reset Servings Count", "reset-servings-count": "Reset Servings Count",

View file

@ -60,7 +60,7 @@
</template> </template>
<template #[`item.actions`]="{ item }"> <template #[`item.actions`]="{ item }">
<v-tooltip <v-tooltip
bottom location="bottom"
:disabled="!(item && (item.households!.length > 0 || item.users!.length > 0))" :disabled="!(item && (item.households!.length > 0 || item.users!.length > 0))"
> >
<template #activator="{ props }"> <template #activator="{ props }">

View file

@ -82,7 +82,7 @@
</template> </template>
<template #[`item.actions`]="{ item }"> <template #[`item.actions`]="{ item }">
<v-tooltip <v-tooltip
bottom location="bottom"
:disabled="!(item && item.users!.length > 0)" :disabled="!(item && item.users!.length > 0)"
> >
<template #activator="{ props }"> <template #activator="{ props }">

View file

@ -16,7 +16,7 @@
@cancel="deleteCreateTarget()" @cancel="deleteCreateTarget()"
> >
<v-card-text> <v-card-text>
<CookbookEditor :key="createTargetKey" v-model="createTarget" :actions="actions" /> <CookbookEditor :key="createTargetKey" v-model="createTarget" />
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
@ -105,9 +105,7 @@
<v-expansion-panel-text> <v-expansion-panel-text>
<CookbookEditor <CookbookEditor
v-model="myCookbooks[index]" v-model="myCookbooks[index]"
:actions="actions"
:collapsable="false" :collapsable="false"
@delete="deleteEventHandler"
/> />
<v-card-actions> <v-card-actions>
<v-spacer /> <v-spacer />

View file

@ -103,7 +103,12 @@
<RecipeIngredientEditor <RecipeIngredientEditor
v-model="parsedIng[index].ingredient" v-model="parsedIng[index].ingredient"
allow-insert-ingredient allow-insert-ingredient
@insert-ingredient="insertIngredient(index)" :unit-error="errors[index].unitError && errors[index].unitErrorMessage !== ''"
:unit-error-tooltip="$t('recipe.parser.this-unit-could-not-be-parsed-automatically')"
:food-error="errors[index].foodError && errors[index].foodErrorMessage !== ''"
:food-error-tooltip="$t('recipe.parser.this-food-could-not-be-parsed-automatically')"
@insert-above="insertIngredient(index)"
@insert-below="insertIngredient(index + 1)"
@delete="deleteIngredient(index)" @delete="deleteIngredient(index)"
/> />
{{ ing.input }} {{ ing.input }}
@ -113,7 +118,7 @@
v-if="errors[index].unitError && errors[index].unitErrorMessage !== ''" v-if="errors[index].unitError && errors[index].unitErrorMessage !== ''"
color="warning" color="warning"
size="small" size="small"
@click="createUnit(ing.ingredient.unit!, index)" @click="createUnit(errors[index].unitName, index)"
> >
{{ errors[index].unitErrorMessage }} {{ errors[index].unitErrorMessage }}
</BaseButton> </BaseButton>
@ -121,7 +126,7 @@
v-if="errors[index].foodError && errors[index].foodErrorMessage !== ''" v-if="errors[index].foodError && errors[index].foodErrorMessage !== ''"
color="warning" color="warning"
size="small" size="small"
@click="createFood(ing.ingredient.food!, index)" @click="createFood(errors[index].foodName, index)"
> >
{{ errors[index].foodErrorMessage }} {{ errors[index].foodErrorMessage }}
</BaseButton> </BaseButton>
@ -157,8 +162,10 @@ import type { Parser } from "~/lib/api/user/recipes/recipe";
interface Error { interface Error {
ingredientIndex: number; ingredientIndex: number;
unitName: string;
unitError: boolean; unitError: boolean;
unitErrorMessage: string; unitErrorMessage: string;
foodName: string;
foodError: boolean; foodError: boolean;
foodErrorMessage: string; foodErrorMessage: string;
} }
@ -224,30 +231,33 @@ export default defineNuxtComponent({
const unitError = !checkForUnit(ing.ingredient.unit!); const unitError = !checkForUnit(ing.ingredient.unit!);
const foodError = !checkForFood(ing.ingredient.food!); const foodError = !checkForFood(ing.ingredient.food!);
const unit = ing.ingredient.unit?.name || i18n.t("recipe.parser.no-unit");
const food = ing.ingredient.food?.name || i18n.t("recipe.parser.no-food");
let unitErrorMessage = ""; let unitErrorMessage = "";
let foodErrorMessage = ""; let foodErrorMessage = "";
if (unitError || foodError) { if (unitError) {
if (unitError) { if (ing?.ingredient?.unit?.name) {
if (ing?.ingredient?.unit?.name) { ing.ingredient.unit = undefined;
const unit = ing.ingredient.unit.name || i18n.t("recipe.parser.no-unit"); unitErrorMessage = i18n.t("recipe.parser.missing-unit", { unit }).toString();
unitErrorMessage = i18n.t("recipe.parser.missing-unit", { unit }).toString();
}
} }
}
if (foodError) { if (foodError) {
if (ing?.ingredient?.food?.name) { if (ing?.ingredient?.food?.name) {
const food = ing.ingredient.food.name || i18n.t("recipe.parser.no-food"); ing.ingredient.food = undefined;
foodErrorMessage = i18n.t("recipe.parser.missing-food", { food }).toString(); foodErrorMessage = i18n.t("recipe.parser.missing-food", { food }).toString();
}
} }
} }
panels.value.push(index); panels.value.push(index);
return { return {
ingredientIndex: index, ingredientIndex: index,
unitName: unit,
unitError, unitError,
unitErrorMessage, unitErrorMessage,
foodName: food,
foodError, foodError,
foodErrorMessage, foodErrorMessage,
} as Error; } as Error;
@ -320,24 +330,24 @@ export default defineNuxtComponent({
return !!food?.id; return !!food?.id;
} }
async function createFood(food: CreateIngredientFood | undefined, index: number) { async function createFood(foodName: string, index: number) {
if (!food) { if (!foodName) {
return; return;
} }
foodData.data.name = food.name; foodData.data.name = foodName;
parsedIng.value[index].ingredient.food = await foodStore.actions.createOne(foodData.data) || undefined; parsedIng.value[index].ingredient.food = await foodStore.actions.createOne(foodData.data) || undefined;
errors.value[index].foodError = false; errors.value[index].foodError = false;
foodData.reset(); foodData.reset();
} }
async function createUnit(unit: CreateIngredientUnit | undefined, index: number) { async function createUnit(unitName: string | undefined, index: number) {
if (!unit) { if (!unitName) {
return; return;
} }
unitData.data.name = unit.name; unitData.data.name = unitName;
parsedIng.value[index].ingredient.unit = await unitStore.actions.createOne(unitData.data) || undefined; parsedIng.value[index].ingredient.unit = await unitStore.actions.createOne(unitData.data) || undefined;
errors.value[index].unitError = false; errors.value[index].unitError = false;

37
poetry.lock generated
View file

@ -3269,30 +3269,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.12.5" version = "0.12.6"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "ruff-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:1de2c887e9dec6cb31fcb9948299de5b2db38144e66403b9660c9548a67abd92"}, {file = "ruff-0.12.6-py3-none-linux_armv6l.whl", hash = "sha256:59b48d8581989e0527b64c3297e672357c03b78d58cf1b228037a49915316277"},
{file = "ruff-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1ab65e7d8152f519e7dea4de892317c9da7a108da1c56b6a3c1d5e7cf4c5e9a"}, {file = "ruff-0.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:412518260394e8a6647a0c610062cac48ff230d39b9df57faae93aa77123e90c"},
{file = "ruff-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:962775ed5b27c7aa3fdc0d8f4d4433deae7659ef99ea20f783d666e77338b8cf"}, {file = "ruff-0.12.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b56a3f51a27d0db8141d5b4b095c2849b24f639539a05d201f72f8d83f829a78"},
{file = "ruff-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b4cae449597e7195a49eb1cdca89fd9fbb16140c7579899e87f4c85bf82f73"}, {file = "ruff-0.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ef9e292957bd6a868ce4e5f57931d0583814a363add2adedae3a1c9854b7ad9"},
{file = "ruff-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b13489c3dc50de5e2d40110c0cce371e00186b880842e245186ca862bf9a1ac"}, {file = "ruff-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c3fd9955d3009c33e60bb596ea7bc66832de34d621883061114bb3b6114d358"},
{file = "ruff-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1504fea81461cf4841778b3ef0a078757602a3b3ea4b008feb1308cb3f23e08"}, {file = "ruff-0.12.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e7456efef8dd6957843de60a245152e34a842210d8b13381d5f3e7540d17935"},
{file = "ruff-0.12.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c7da4129016ae26c32dfcbd5b671fe652b5ab7fc40095d80dcff78175e7eddd4"}, {file = "ruff-0.12.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c99e62bae20c7e1a8d4de84f96754e9732d0831614ed165415ed2c4f4aa83864"},
{file = "ruff-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca972c80f7ebcfd8af75a0f18b17c42d9f1ef203d163669150453f50ca98ab7b"}, {file = "ruff-0.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d47ff2b300da87df8437e1b35291349faaceb666d8349edef733b6562d29264f"},
{file = "ruff-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8dbbf9f25dfb501f4237ae7501d6364b76a01341c6f1b2cd6764fe449124bb2a"}, {file = "ruff-0.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8883ab5e9506574a6a2abacb5da34d416fdd8434151b35421ba3f79ca9a14a11"},
{file = "ruff-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c47dea6ae39421851685141ba9734767f960113d51e83fd7bb9958d5be8763a"}, {file = "ruff-0.12.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3cfbd192c312669fb22cd4bf8c700e8b4b1dced7ce034e581459c0e375486fa"},
{file = "ruff-0.12.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5076aa0e61e30f848846f0265c873c249d4b558105b221be1828f9f79903dc5"}, {file = "ruff-0.12.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c1d87f2b1abf330281b3972d6bf34d366ee84b3077df66a89169e2d81b291891"},
{file = "ruff-0.12.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5a4c7830dadd3d8c39b1cc85386e2c1e62344f20766be6f173c22fb5f72f293"}, {file = "ruff-0.12.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3f32aaa9b5ed69de80693abeecf9961cd97851cadf7850081461261d0e6551b6"},
{file = "ruff-0.12.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:46699f73c2b5b137b9dc0fc1a190b43e35b008b398c6066ea1350cce6326adcb"}, {file = "ruff-0.12.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:de5185f19289a800c16d6ec8a9ba0b8b911b4640a4927b487f48fb51634ce315"},
{file = "ruff-0.12.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a655a0a0d396f0f072faafc18ebd59adde8ca85fb848dc1b0d9f024b9c4d3bb"}, {file = "ruff-0.12.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80f9d56205f6f6c4a1039c79d9acc0a9c104915f4fc0fc0385170decc72f6e4c"},
{file = "ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9"}, {file = "ruff-0.12.6-py3-none-win32.whl", hash = "sha256:b553271d6ed5611fcbe5f6752852eef695f2a77c0405b3a16fd507e5a057f5b0"},
{file = "ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5"}, {file = "ruff-0.12.6-py3-none-win_amd64.whl", hash = "sha256:48b73d4acef6768bfe9912e8f623ec87677bcfb6dc748ac406ebff06a84a6d70"},
{file = "ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805"}, {file = "ruff-0.12.6-py3-none-win_arm64.whl", hash = "sha256:cd2c9c898a11f1441778d1cf9e358244cf5f4f2f11e93ff03c1a6c6759f4b15d"},
{file = "ruff-0.12.5.tar.gz", hash = "sha256:b209db6102b66f13625940b7f8c7d0f18e20039bb7f6101fbdac935c9612057e"},
] ]
[[package]] [[package]]