diff --git a/alembic/versions/2024-10-08-21.17.31_86054b40fd06_added_query_filter_string_to_cookbook_.py b/alembic/versions/2024-10-08-21.17.31_86054b40fd06_added_query_filter_string_to_cookbook_.py new file mode 100644 index 00000000000..3f5eb908c8d --- /dev/null +++ b/alembic/versions/2024-10-08-21.17.31_86054b40fd06_added_query_filter_string_to_cookbook_.py @@ -0,0 +1,188 @@ +"""added query_filter_string to cookbook and mealplan + +Revision ID: 86054b40fd06 +Revises: 602927e1013e +Create Date: 2024-10-08 21:17:31.601903 + +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from alembic import op +from mealie.db.models._model_utils import guid + +# revision identifiers, used by Alembic. +revision = "86054b40fd06" +down_revision: str | None = "602927e1013e" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +# Intermediate table definitions +class SqlAlchemyBase(orm.DeclarativeBase): + pass + + +class Category(SqlAlchemyBase): + __tablename__ = "categories" + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + + +class Tag(SqlAlchemyBase): + __tablename__ = "tags" + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + + +class Tool(SqlAlchemyBase): + __tablename__ = "tools" + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + + +class Household(SqlAlchemyBase): + __tablename__ = "households" + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + + +cookbooks_to_categories = sa.Table( + "cookbooks_to_categories", + SqlAlchemyBase.metadata, + sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id"), index=True), + sa.Column("category_id", guid.GUID, sa.ForeignKey("categories.id"), index=True), +) + +cookbooks_to_tags = sa.Table( + "cookbooks_to_tags", + SqlAlchemyBase.metadata, + sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id"), index=True), + sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id"), index=True), +) + +cookbooks_to_tools = sa.Table( + "cookbooks_to_tools", + SqlAlchemyBase.metadata, + sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id"), index=True), + sa.Column("tool_id", guid.GUID, sa.ForeignKey("tools.id"), index=True), +) + +plan_rules_to_categories = sa.Table( + "plan_rules_to_categories", + SqlAlchemyBase.metadata, + sa.Column("group_plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id"), index=True), + sa.Column("category_id", guid.GUID, sa.ForeignKey("categories.id"), index=True), +) + +plan_rules_to_tags = sa.Table( + "plan_rules_to_tags", + SqlAlchemyBase.metadata, + sa.Column("plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id"), index=True), + sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id"), index=True), +) + +plan_rules_to_households = sa.Table( + "plan_rules_to_households", + SqlAlchemyBase.metadata, + sa.Column("group_plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id"), index=True), + sa.Column("household_id", guid.GUID, sa.ForeignKey("households.id"), index=True), +) + + +class CookBook(SqlAlchemyBase): + __tablename__ = "cookbooks" + + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + query_filter_string: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False, default="") + + categories: orm.Mapped[list[Category]] = orm.relationship( + Category, secondary=cookbooks_to_categories, single_parent=True + ) + require_all_categories: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=True) + + tags: orm.Mapped[list[Tag]] = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True) + require_all_tags: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=True) + + tools: orm.Mapped[list[Tool]] = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True) + require_all_tools: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=True) + + +class GroupMealPlanRules(SqlAlchemyBase): + __tablename__ = "group_meal_plan_rules" + + id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) + query_filter_string: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False, default="") + + categories: orm.Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories) + tags: orm.Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags) + households: orm.Mapped[list["Household"]] = orm.relationship("Household", secondary=plan_rules_to_households) + + +def migrate_cookbooks(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + cookbooks = session.query(CookBook).all() + for cookbook in cookbooks: + parts = [] + if cookbook.categories: + relop = "CONTAINS ALL" if cookbook.require_all_categories else "IN" + vals = ",".join([f'"{cat.id}"' for cat in cookbook.categories]) + parts.append(f"recipe_category.id {relop} [{vals}]") + if cookbook.tags: + relop = "CONTAINS ALL" if cookbook.require_all_tags else "IN" + vals = ",".join([f'"{tag.id}"' for tag in cookbook.tags]) + parts.append(f"tags.id {relop} [{vals}]") + if cookbook.tools: + relop = "CONTAINS ALL" if cookbook.require_all_tools else "IN" + vals = ",".join([f'"{tool.id}"' for tool in cookbook.tools]) + parts.append(f"tools.id {relop} [{vals}]") + + cookbook.query_filter_string = " AND ".join(parts) + + session.commit() + + +def migrate_mealplan_rules(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + rules = session.query(GroupMealPlanRules).all() + for rule in rules: + parts = [] + if rule.categories: + vals = ",".join([f'"{cat.id}"' for cat in rule.categories]) + parts.append(f"recipe_category.id CONTAINS ALL [{vals}]") + if rule.tags: + vals = ",".join([f'"{tag.id}"' for tag in rule.tags]) + parts.append(f"tags.id CONTAINS ALL [{vals}]") + if rule.households: + vals = ",".join([f'"{household.id}"' for household in rule.households]) + parts.append(f"household_id IN [{vals}]") + + rule.query_filter_string = " AND ".join(parts) + + session.commit() + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("cookbooks", schema=None) as batch_op: + batch_op.add_column(sa.Column("query_filter_string", sa.String(), nullable=False, server_default="")) + + with op.batch_alter_table("group_meal_plan_rules", schema=None) as batch_op: + batch_op.add_column(sa.Column("query_filter_string", sa.String(), nullable=False, server_default="")) + + # ### end Alembic commands ### + + migrate_cookbooks() + migrate_mealplan_rules() + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("group_meal_plan_rules", schema=None) as batch_op: + batch_op.drop_column("query_filter_string") + + with op.batch_alter_table("cookbooks", schema=None) as batch_op: + batch_op.drop_column("query_filter_string") + + # ### end Alembic commands ### diff --git a/frontend/components/Domain/Cookbook/CookbookEditor.vue b/frontend/components/Domain/Cookbook/CookbookEditor.vue index 6f733f485b9..56dbf525db8 100644 --- a/frontend/components/Domain/Cookbook/CookbookEditor.vue +++ b/frontend/components/Domain/Cookbook/CookbookEditor.vue @@ -1,11 +1,13 @@ - + - - - + {{ $t('cookbook.public-cookbook') }} @@ -14,33 +16,19 @@ - - - {{ $t('cookbook.filter-options') }} - - {{ $t('cookbook.filter-options-description') }} - - - - {{ $t('cookbook.require-all-categories') }} - - - {{ $t('cookbook.require-all-tags') }} - - - {{ $t('cookbook.require-all-tools') }} - - diff --git a/frontend/components/Domain/Cookbook/CookbookPage.vue b/frontend/components/Domain/Cookbook/CookbookPage.vue index 722d2b0fb6d..44486e70dba 100644 --- a/frontend/components/Domain/Cookbook/CookbookPage.vue +++ b/frontend/components/Domain/Cookbook/CookbookPage.vue @@ -4,11 +4,13 @@ diff --git a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue index 1f263eb3621..06f03d5ee51 100644 --- a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue +++ b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue @@ -6,12 +6,10 @@ - - - @@ -25,14 +23,14 @@ + + diff --git a/frontend/components/Domain/Recipe/RecipeOrganizerPage.vue b/frontend/components/Domain/Recipe/RecipeOrganizerPage.vue index 375d32e944a..d00172b01fe 100644 --- a/frontend/components/Domain/Recipe/RecipeOrganizerPage.vue +++ b/frontend/components/Domain/Recipe/RecipeOrganizerPage.vue @@ -143,7 +143,9 @@ export default defineComponent({ const typeMap = { "categories": "category.category", "tags": "tag.tag", - "tools": "tool.tool" + "tools": "tool.tool", + "foods": "shopping-list.food", + "households": "household.household", }; return typeMap[props.itemType] || ""; }); diff --git a/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue b/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue index 658f043574b..26dd1a84d4b 100644 --- a/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue +++ b/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue @@ -8,13 +8,12 @@ deletable-chips item-text="name" multiple - :prepend-inner-icon="selectorType === Organizer.Tool ? $globals.icons.potSteam : - selectorType === Organizer.Category ? $globals.icons.categories : - $globals.icons.tags" + :prepend-inner-icon="icon" return-object v-bind="inputAttrs" auto-select-first :search-input.sync="searchInput" + class="pa-0" @change="resetSearchInput" > @@ -46,11 +45,11 @@ + + diff --git a/frontend/components/global/BaseDialog.vue b/frontend/components/global/BaseDialog.vue index 019b1ecf177..9fe32100963 100644 --- a/frontend/components/global/BaseDialog.vue +++ b/frontend/components/global/BaseDialog.vue @@ -61,7 +61,7 @@ {{ $t("general.confirm") }} - + {{ submitText }} {{ submitIcon }} @@ -125,6 +125,10 @@ export default defineComponent({ return this.$t("general.create"); }, }, + submitDisabled: { + type: Boolean, + default: false, + }, keepOpen: { default: false, type: Boolean, diff --git a/frontend/composables/partials/use-actions-factory.ts b/frontend/composables/partials/use-actions-factory.ts index 035fc7a11e4..3bcb5983f6e 100644 --- a/frontend/composables/partials/use-actions-factory.ts +++ b/frontend/composables/partials/use-actions-factory.ts @@ -51,6 +51,9 @@ export function useReadOnlyActions( } async function refresh(page = 1, perPage = -1, params = {} as Record) { + params.orderBy ??= "name"; + params.orderDirection ??= "asc"; + loading.value = true; const { data } = await api.getAll(page, perPage, params); @@ -102,6 +105,9 @@ export function useStoreActions( } async function refresh(page = 1, perPage = -1, params = {} as Record) { + params.orderBy ??= "name"; + params.orderDirection ??= "asc"; + loading.value = true; const { data } = await api.getAll(page, perPage, params); diff --git a/frontend/composables/use-group-cookbooks.ts b/frontend/composables/use-group-cookbooks.ts index c8f19c6e19c..b9e0eee58ce 100644 --- a/frontend/composables/use-group-cookbooks.ts +++ b/frontend/composables/use-group-cookbooks.ts @@ -103,6 +103,8 @@ export const useCookbooks = function () { loading.value = true; const { data } = await api.cookbooks.createOne({ name: i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((cookbookStore?.value?.length ?? 0) + 1)]) as string, + position: (cookbookStore?.value?.length ?? 0) + 1, + queryFilterString: "", }); if (data && cookbookStore?.value) { cookbookStore.value.push(data); diff --git a/frontend/composables/use-query-filter-builder.ts b/frontend/composables/use-query-filter-builder.ts new file mode 100644 index 00000000000..30c40cdd06e --- /dev/null +++ b/frontend/composables/use-query-filter-builder.ts @@ -0,0 +1,318 @@ +import { computed, useContext } from "@nuxtjs/composition-api"; +import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated"; +import { LogicalOperator, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response"; + +export interface FieldLogicalOperator { + label: string; + value: LogicalOperator; +} + +export interface FieldRelationalOperator { + label: string; + value: RelationalKeyword | RelationalOperator; +} + +export interface OrganizerBase { + id: string; + slug: string; + name: string; +} + +export type FieldType = + | "string" + | "number" + | "boolean" + | "date" + | RecipeOrganizer; + +export type FieldValue = + | string + | number + | boolean + | Date + | Organizer; + +export interface SelectableItem { + label: string; + value: FieldValue; +}; + +export interface FieldDefinition { + name: string; + label: string; + type: FieldType; + + // only for select/organizer fields + fieldOptions?: SelectableItem[]; +} + +export interface Field extends FieldDefinition { + leftParenthesis?: string; + logicalOperator?: FieldLogicalOperator; + value: FieldValue; + relationalOperatorValue: FieldRelationalOperator; + relationalOperatorOptions: FieldRelationalOperator[]; + rightParenthesis?: string; + + // only for select/organizer fields + values: FieldValue[]; + organizers: OrganizerBase[]; +} + +export function useQueryFilterBuilder() { + const { i18n } = useContext(); + + const logOps = computed>(() => { + const AND = { + label: i18n.tc("query-filter.logical-operators.and"), + value: "AND", + } as FieldLogicalOperator; + + const OR = { + label: i18n.tc("query-filter.logical-operators.or"), + value: "OR", + } as FieldLogicalOperator; + + return { + AND, + OR, + }; + }); + + const relOps = computed>(() => { + const EQ = { + label: i18n.tc("query-filter.relational-operators.equals"), + value: "=", + } as FieldRelationalOperator; + + const NOT_EQ = { + label: i18n.tc("query-filter.relational-operators.does-not-equal"), + value: "<>", + } as FieldRelationalOperator; + + const GT = { + label: i18n.tc("query-filter.relational-operators.is-greater-than"), + value: ">", + } as FieldRelationalOperator; + + const GTE = { + label: i18n.tc("query-filter.relational-operators.is-greater-than-or-equal-to"), + value: ">=", + } as FieldRelationalOperator; + + const LT = { + label: i18n.tc("query-filter.relational-operators.is-less-than"), + value: "<", + } as FieldRelationalOperator; + + const LTE = { + label: i18n.tc("query-filter.relational-operators.is-less-than-or-equal-to"), + value: "<=", + } as FieldRelationalOperator; + + const IS = { + label: i18n.tc("query-filter.relational-keywords.is"), + value: "IS", + } as FieldRelationalOperator; + + const IS_NOT = { + label: i18n.tc("query-filter.relational-keywords.is-not"), + value: "IS NOT", + } as FieldRelationalOperator; + + const IN = { + label: i18n.tc("query-filter.relational-keywords.is-one-of"), + value: "IN", + } as FieldRelationalOperator; + + const NOT_IN = { + label: i18n.tc("query-filter.relational-keywords.is-not-one-of"), + value: "NOT IN", + } as FieldRelationalOperator; + + const CONTAINS_ALL = { + label: i18n.tc("query-filter.relational-keywords.contains-all-of"), + value: "CONTAINS ALL", + } as FieldRelationalOperator; + + const LIKE = { + label: i18n.tc("query-filter.relational-keywords.is-like"), + value: "LIKE", + } as FieldRelationalOperator; + + const NOT_LIKE = { + label: i18n.tc("query-filter.relational-keywords.is-not-like"), + value: "NOT LIKE", + } as FieldRelationalOperator; + + /* eslint-disable object-shorthand */ + return { + "=": EQ, + "<>": NOT_EQ, + ">": GT, + ">=": GTE, + "<": LT, + "<=": LTE, + "IS": IS, + "IS NOT": IS_NOT, + "IN": IN, + "NOT IN": NOT_IN, + "CONTAINS ALL": CONTAINS_ALL, + "LIKE": LIKE, + "NOT LIKE": NOT_LIKE, + }; + /* eslint-enable object-shorthand */ + }); + + function isOrganizerType(type: FieldType): type is Organizer { + return ( + type === Organizer.Category || + type === Organizer.Tag || + type === Organizer.Tool || + type === Organizer.Food || + type === Organizer.Household + ); + }; + + function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field { + /* eslint-disable dot-notation */ + const updatedField = {logicalOperator: logOps.value.AND, ...field} as Field; + let operatorOptions: FieldRelationalOperator[]; + if (updatedField.fieldOptions?.length || isOrganizerType(updatedField.type)) { + operatorOptions = [ + relOps.value["IN"], + relOps.value["NOT IN"], + relOps.value["CONTAINS ALL"], + ]; + } else { + switch (updatedField.type) { + case "string": + operatorOptions = [ + relOps.value["="], + relOps.value["<>"], + relOps.value["LIKE"], + relOps.value["NOT LIKE"], + ]; + break; + case "number": + operatorOptions = [ + relOps.value["="], + relOps.value["<>"], + relOps.value[">"], + relOps.value[">="], + relOps.value["<"], + relOps.value["<="], + ]; + break; + case "boolean": + operatorOptions = [relOps.value["="]]; + break; + case "date": + operatorOptions = [ + relOps.value["="], + relOps.value["<>"], + relOps.value[">"], + relOps.value[">="], + relOps.value["<"], + relOps.value["<="], + ]; + break; + default: + operatorOptions = [relOps.value["="], relOps.value["<>"]]; + } + } + updatedField.relationalOperatorOptions = operatorOptions; + if (!operatorOptions.includes(updatedField.relationalOperatorValue)) { + updatedField.relationalOperatorValue = operatorOptions[0]; + } + + if (resetValue) { + updatedField.value = ""; + updatedField.values = []; + updatedField.organizers = []; + } else { + updatedField.value = updatedField.value || ""; + updatedField.values = updatedField.values || []; + updatedField.organizers = updatedField.organizers || []; + } + + return updatedField; + /* eslint-enable dot-notation */ + }; + + function buildQueryFilterString(fields: Field[], useParenthesis: boolean): string { + let isValid = true; + let lParenCounter = 0; + let rParenCounter = 0; + + const parts: string[] = []; + fields.forEach((field, index) => { + if (index) { + if (!field.logicalOperator) { + field.logicalOperator = logOps.value.AND; + } + parts.push(field.logicalOperator.value); + } + + if (field.leftParenthesis && useParenthesis) { + lParenCounter += field.leftParenthesis.length; + parts.push(field.leftParenthesis); + } + + if (field.label) { + parts.push(field.name); + } else { + isValid = false; + } + + if (field.relationalOperatorValue) { + parts.push(field.relationalOperatorValue.value); + } else if (field.type !== "boolean") { + isValid = false; + } + + if (field.fieldOptions?.length || isOrganizerType(field.type)) { + if (field.values?.length) { + let val: string; + if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) { + val = field.values.map((value) => `"${value.toString()}"`).join(","); + } else { + val = field.values.join(","); + } + parts.push(`[${val}]`); + } else { + isValid = false; + } + } else if (field.value) { + if (field.type === "string" || field.type === "date") { + parts.push(`"${field.value.toString()}"`); + } else { + parts.push(field.value.toString()); + } + } else if (field.type === "boolean") { + parts.push("false"); + } else { + isValid = false; + } + + if (field.rightParenthesis && useParenthesis) { + rParenCounter += field.rightParenthesis.length; + parts.push(field.rightParenthesis); + } + }); + + if (lParenCounter !== rParenCounter) { + isValid = false; + } + + return isValid ? parts.join(" ") : ""; + } + + return { + logOps, + relOps, + buildQueryFilterString, + getFieldFromFieldDef, + isOrganizerType, + }; +} diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index a58580d8be7..0a4a57e8324 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -212,7 +212,11 @@ "clipboard-copy-failure": "Failed to copy to the clipboard.", "confirm-delete-generic-items": "Are you sure you want to delete the following items?", "organizers": "Organizers", - "caution": "Caution" + "caution": "Caution", + "show-advanced": "Show Advanced", + "add-field": "Add Field", + "date-created": "Date Created", + "date-updated": "Date Updated" }, "group": { "are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete {groupName}?", @@ -351,7 +355,7 @@ "for-type-meal-types": "for {0} meal types", "meal-plan-rules": "Meal Plan Rules", "new-rule": "New Rule", - "meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the categories of the rules will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.", + "meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the rule filters will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.", "new-rule-description": "When creating a new rule for a meal plan you can restrict the rule to be applicable for a specific day of the week and/or a specific type of meal. To apply a rule to all days or all meal types you can set the rule to \"Any\" which will apply it to all the possible values for the day and/or meal type.", "recipe-rules": "Recipe Rules", "applies-to-all-days": "Applies to all days", @@ -1319,7 +1323,7 @@ }, "cookbook": { "cookbooks": "Cookbooks", - "description": "Cookbooks are another way to organize recipes by creating cross sections of recipes and tags. Creating a cookbook will add an entry to the side-bar and all the recipes with the tags and categories chosen will be displayed in the cookbook.", + "description": "Cookbooks are another way to organize recipes by creating cross sections of recipes, organizers, and other filters. Creating a cookbook will add an entry to the side-bar and all the recipes with the filters chosen will be displayed in the cookbook.", "public-cookbook": "Public Cookbook", "public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.", "filter-options": "Filter Options", @@ -1332,5 +1336,28 @@ "household-cookbook-name": "{0} Cookbook {1}", "create-a-cookbook": "Create a Cookbook", "cookbook": "Cookbook" + }, + "query-filter": { + "logical-operators": { + "and": "AND", + "or": "OR" + }, + "relational-operators": { + "equals": "equals", + "does-not-equal": "does not equal", + "is-greater-than": "is greater than", + "is-greater-than-or-equal-to": "is greater than or equal to", + "is-less-than": "is less than", + "is-less-than-or-equal-to": "is less than or equal to" + }, + "relational-keywords": { + "is": "is", + "is-not": "is not", + "is-one-of": "is one of", + "is-not-one-of": "is not one of", + "contains-all-of": "contains all of", + "is-like": "is like", + "is-not-like": "is not like" + } } } diff --git a/frontend/lib/api/types/cookbook.ts b/frontend/lib/api/types/cookbook.ts index d9761b4c052..e43e48bcbf9 100644 --- a/frontend/lib/api/types/cookbook.ts +++ b/frontend/lib/api/types/cookbook.ts @@ -5,34 +5,17 @@ /* Do not modify it by hand - just update the pydantic models and then re-run the script */ +export type LogicalOperator = "AND" | "OR"; +export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE"; +export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<="; + export interface CreateCookBook { name: string; description?: string; slug?: string | null; position?: number; public?: boolean; - categories?: CategoryBase[]; - tags?: TagBase[]; - tools?: RecipeTool[]; - requireAllCategories?: boolean; - requireAllTags?: boolean; - requireAllTools?: boolean; -} -export interface CategoryBase { - name: string; - id: string; - slug: string; -} -export interface TagBase { - name: string; - id: string; - slug: string; -} -export interface RecipeTool { - id: string; - name: string; - slug: string; - onHand?: boolean; + queryFilterString: string; } export interface ReadCookBook { name: string; @@ -40,15 +23,23 @@ export interface ReadCookBook { slug?: string | null; position?: number; public?: boolean; - categories?: CategoryBase[]; - tags?: TagBase[]; - tools?: RecipeTool[]; - requireAllCategories?: boolean; - requireAllTags?: boolean; - requireAllTools?: boolean; + queryFilterString: string; groupId: string; householdId: string; id: string; + queryFilter: QueryFilterJSON; +} +export interface QueryFilterJSON { + parts?: QueryFilterJSONPart[]; +} +export interface QueryFilterJSONPart { + leftParenthesis?: string | null; + rightParenthesis?: string | null; + logicalOperator?: LogicalOperator | null; + attributeName?: string | null; + relationalOperator?: RelationalKeyword | RelationalOperator | null; + value?: string | string[] | null; + [k: string]: unknown; } export interface RecipeCookBook { name: string; @@ -56,15 +47,11 @@ export interface RecipeCookBook { slug?: string | null; position?: number; public?: boolean; - categories?: CategoryBase[]; - tags?: TagBase[]; - tools?: RecipeTool[]; - requireAllCategories?: boolean; - requireAllTags?: boolean; - requireAllTools?: boolean; + queryFilterString: string; groupId: string; householdId: string; id: string; + queryFilter: QueryFilterJSON; recipes: RecipeSummary[]; } export interface RecipeSummary { @@ -104,18 +91,20 @@ export interface RecipeTag { slug: string; [k: string]: unknown; } +export interface RecipeTool { + id: string; + name: string; + slug: string; + onHand?: boolean; + [k: string]: unknown; +} export interface SaveCookBook { name: string; description?: string; slug?: string | null; position?: number; public?: boolean; - categories?: CategoryBase[]; - tags?: TagBase[]; - tools?: RecipeTool[]; - requireAllCategories?: boolean; - requireAllTags?: boolean; - requireAllTools?: boolean; + queryFilterString: string; groupId: string; householdId: string; } @@ -125,12 +114,7 @@ export interface UpdateCookBook { slug?: string | null; position?: number; public?: boolean; - categories?: CategoryBase[]; - tags?: TagBase[]; - tools?: RecipeTool[]; - requireAllCategories?: boolean; - requireAllTags?: boolean; - requireAllTools?: boolean; + queryFilterString: string; groupId: string; householdId: string; id: string; diff --git a/frontend/lib/api/types/meal-plan.ts b/frontend/lib/api/types/meal-plan.ts index 5d376eab36d..ae033c1f45b 100644 --- a/frontend/lib/api/types/meal-plan.ts +++ b/frontend/lib/api/types/meal-plan.ts @@ -8,6 +8,9 @@ export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side"; export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" | "unset"; export type PlanRulesType = "breakfast" | "lunch" | "dinner" | "side" | "unset"; +export type LogicalOperator = "AND" | "OR"; +export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE"; +export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<="; export interface Category { id: string; @@ -31,44 +34,36 @@ export interface ListItem { quantity?: number; checked?: boolean; } -export interface PlanCategory { - id: string; - name: string; - slug: string; -} -export interface PlanHousehold { - id: string; - name: string; - slug: string; -} export interface PlanRulesCreate { day?: PlanRulesDay & string; entryType?: PlanRulesType & string; - categories?: PlanCategory[]; - tags?: PlanTag[]; - households?: PlanHousehold[]; -} -export interface PlanTag { - id: string; - name: string; - slug: string; + queryFilterString: string; } export interface PlanRulesOut { day?: PlanRulesDay & string; entryType?: PlanRulesType & string; - categories?: PlanCategory[]; - tags?: PlanTag[]; - households?: PlanHousehold[]; + queryFilterString: string; groupId: string; householdId: string; id: string; + queryFilter: QueryFilterJSON; +} +export interface QueryFilterJSON { + parts?: QueryFilterJSONPart[]; +} +export interface QueryFilterJSONPart { + leftParenthesis?: string | null; + rightParenthesis?: string | null; + logicalOperator?: LogicalOperator | null; + attributeName?: string | null; + relationalOperator?: RelationalKeyword | RelationalOperator | null; + value?: string | string[] | null; + [k: string]: unknown; } export interface PlanRulesSave { day?: PlanRulesDay & string; entryType?: PlanRulesType & string; - categories?: PlanCategory[]; - tags?: PlanTag[]; - households?: PlanHousehold[]; + queryFilterString: string; groupId: string; householdId: string; } @@ -126,6 +121,7 @@ export interface RecipeTool { name: string; slug: string; onHand?: boolean; + [k: string]: unknown; } export interface SavePlanEntry { date: string; diff --git a/frontend/lib/api/types/non-generated.ts b/frontend/lib/api/types/non-generated.ts index 445359daa59..79ebd9183fc 100644 --- a/frontend/lib/api/types/non-generated.ts +++ b/frontend/lib/api/types/non-generated.ts @@ -24,10 +24,17 @@ export interface PaginationData { items: T[]; } -export type RecipeOrganizer = "categories" | "tags" | "tools"; +export type RecipeOrganizer = + | "categories" + | "tags" + | "tools" + | "foods" + | "households"; export enum Organizer { Category = "categories", Tag = "tags", Tool = "tools", + Food = "foods", + Household = "households", } diff --git a/frontend/lib/api/types/response.ts b/frontend/lib/api/types/response.ts index 89d4bbdc4f6..2eef08546d8 100644 --- a/frontend/lib/api/types/response.ts +++ b/frontend/lib/api/types/response.ts @@ -7,6 +7,9 @@ export type OrderByNullPosition = "first" | "last"; export type OrderDirection = "asc" | "desc"; +export type LogicalOperator = "AND" | "OR"; +export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE"; +export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<="; export interface ErrorResponse { message: string; @@ -25,6 +28,17 @@ export interface PaginationQuery { queryFilter?: string | null; paginationSeed?: string | null; } +export interface QueryFilterJSON { + parts?: QueryFilterJSONPart[]; +} +export interface QueryFilterJSONPart { + leftParenthesis?: string | null; + rightParenthesis?: string | null; + logicalOperator?: LogicalOperator | null; + attributeName?: string | null; + relationalOperator?: RelationalKeyword | RelationalOperator | null; + value?: string | string[] | null; +} export interface RecipeSearchQuery { cookbook?: string | null; requireAllCategories?: boolean; diff --git a/frontend/lib/api/user/group-mealplan.ts b/frontend/lib/api/user/group-mealplan.ts index 7c748514976..fcc31f2e3b5 100644 --- a/frontend/lib/api/user/group-mealplan.ts +++ b/frontend/lib/api/user/group-mealplan.ts @@ -14,7 +14,6 @@ export class MealPlanAPI extends BaseCRUDAPI(routes.random, payload); } } diff --git a/frontend/pages/g/_groupSlug/cookbooks/index.vue b/frontend/pages/g/_groupSlug/cookbooks/index.vue index d894da2d16c..01117c7b52c 100644 --- a/frontend/pages/g/_groupSlug/cookbooks/index.vue +++ b/frontend/pages/g/_groupSlug/cookbooks/index.vue @@ -4,16 +4,19 @@ @@ -36,7 +39,7 @@ - + @@ -51,7 +54,7 @@ - + @@ -84,6 +87,7 @@ icon: $globals.icons.save, text: $tc('general.save'), event: 'save', + disabled: !cookbook.queryFilterString }, ]" @delete="deleteEventHandler(cookbook)" @@ -99,7 +103,7 @@