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

fix: QueryFilter Hydration & script setup (#5839)

This commit is contained in:
Kuchenpirat 2025-07-29 23:43:13 +02:00 committed by GitHub
parent d6d247f1f8
commit eefe613aaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 534 additions and 658 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

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

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