Merge branch 'mealie-next' into feat/standardize-units

This commit is contained in:
Michael Genson
2026-02-24 17:48:15 +00:00
34 changed files with 1850 additions and 3040 deletions

View File

@@ -0,0 +1,217 @@
<template>
<!-- Create Dialog -->
<BaseDialog
v-model="createDialog"
:title="$t('general.create')"
:icon="icon"
color="primary"
:submit-disabled="!createFormValid"
can-confirm
@confirm="emit('create-one', createForm.data)"
>
<div class="mx-2 mt-2">
<slot name="create-dialog-top" />
<AutoForm
v-model="createForm.data"
v-model:is-valid="createFormValid"
:items="createForm.items"
/>
</div>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
v-model="editDialog"
:title="$t('general.edit')"
:icon="icon"
color="primary"
:submit-disabled="!editFormValid"
can-confirm
@confirm="emit('edit-one', editForm.data)"
>
<div class="mx-2 mt-2">
<AutoForm
v-model="editForm.data"
v-model:is-valid="editFormValid"
:items="editForm.items"
/>
</div>
<template #custom-card-action>
<slot name="edit-dialog-custom-action" />
</template>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="$emit('deleteOne', deleteTarget.id)"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p v-if="deleteTarget" class="mt-4 ml-4">
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
</p>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="bulkDeleteDialog"
width="650px"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="$emit('bulk-action', 'delete-selected', bulkDeleteTarget)"
>
<v-card-text>
<p class="h4">
{{ $t('general.confirm-delete-generic-items') }}
</p>
<v-card variant="outlined">
<v-virtual-scroll height="400" item-height="25" :items="bulkDeleteTarget">
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.name || item.title || item.id }}</v-list-item-title>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle
:icon="icon"
section
:title="title"
/>
<CrudTable
:headers="tableHeaders"
:table-config="tableConfig"
:data="data || []"
:bulk-actions="bulkActions"
:initial-sort="initialSort"
@edit-one="editEventHandler"
@delete-one="deleteEventHandler"
@bulk-action="handleBulkAction"
>
<template
v-for="slotName in itemSlotNames"
#[slotName]="slotProps"
>
<slot
:name="slotName"
v-bind="slotProps"
/>
</template>
<template #button-row>
<BaseButton
create
@click="createDialog = true"
>
{{ $t("general.create") }}
</BaseButton>
<slot name="table-button-row" />
</template>
<template #button-bottom>
<slot name="table-button-bottom" />
</template>
</CrudTable>
</template>
<script setup lang="ts">
import type { TableHeaders, TableConfig, BulkAction } from "~/components/global/CrudTable.vue";
import type { AutoFormItems } from "~/types/auto-forms";
const slots = useSlots();
const emit = defineEmits<{
(e: "deleteOne", id: string): void;
(e: "deleteMany", ids: string[]): void;
(e: "create-one" | "edit-one", data: any): void;
(e: "bulk-action", event: string, items: any[]): void;
}>();
const tableHeaders = defineModel<TableHeaders[]>("tableHeaders", { required: true });
const createForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("createForm", { required: true });
const createDialog = defineModel("createDialog", { type: Boolean, default: false });
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
const editDialog = defineModel("editDialog", { type: Boolean, default: false });
defineProps({
icon: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
tableConfig: {
type: Object as PropType<TableConfig>,
default: () => ({
hideColumns: false,
canExport: true,
}),
},
data: {
type: Array as PropType<Array<any>>,
required: true,
},
bulkActions: {
type: Array as PropType<BulkAction[]>,
required: true,
},
initialSort: {
type: String,
default: "name",
},
});
// ============================================================
// Bulk Action Handler
function handleBulkAction(event: string, items: any[]) {
if (event === "delete-selected") {
bulkDeleteEventHandler(items);
return;
}
emit("bulk-action", event, items);
}
// ============================================================
// Create & Edit
const createFormValid = ref(false);
const editFormValid = ref(false);
const itemSlotNames = computed(() => Object.keys(slots).filter(slotName => slotName.startsWith("item.")));
const editEventHandler = (item: any) => {
editForm.value.data = { ...item };
editDialog.value = true;
};
// ============================================================
// Delete Logic
const deleteTarget = ref<any>(null);
const deleteDialog = ref(false);
function deleteEventHandler(item: any) {
deleteTarget.value = item;
deleteDialog.value = true;
}
// ============================================================
// Bulk Delete Logic
const bulkDeleteTarget = ref<Array<any>>([]);
const bulkDeleteDialog = ref(false);
function bulkDeleteEventHandler(items: Array<any>) {
bulkDeleteTarget.value = items;
bulkDeleteDialog.value = true;
console.log("Bulk Delete Event Handler", items);
}
</script>

View File

@@ -1,211 +1,136 @@
<template>
<v-card
:color="color"
:dark="dark"
flat
:width="width"
class="my-2"
>
<v-row>
<v-col
v-for="(inputField, index) in items"
:key="index"
cols="12"
sm="12"
>
<v-divider
v-if="inputField.section"
class="my-2"
/>
<v-card-title
v-if="inputField.section"
class="pl-0"
<v-form v-model="isValid" validate-on="input">
<v-card
:color="color"
:dark="dark"
flat
:width="width"
class="my-2"
>
<v-row>
<v-col
v-for="(inputField, index) in items"
:key="index"
cols="12"
sm="12"
>
{{ inputField.section }}
</v-card-title>
<v-card-text
v-if="inputField.sectionDetails"
class="pl-0 mt-0 pt-0"
>
{{ inputField.sectionDetails }}
</v-card-text>
<!-- Check Box -->
<v-checkbox
v-if="inputField.type === fieldTypes.BOOLEAN"
v-model="model[inputField.varName]"
:name="inputField.varName"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:hint="inputField.hint"
:hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint"
density="comfortable"
@change="emitBlur"
>
<template #label>
<span class="ml-4">
{{ inputField.label }}
</span>
</template>
</v-checkbox>
<!-- Text Field -->
<v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
variant="solo-filled"
flat
:autofocus="index === 0"
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules as any), ...defaultRules] : []"
lazy-validation
@blur="emitBlur"
/>
<!-- Text Area -->
<v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
rows="3"
auto-grow
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="[...rulesByKey(inputField.rules as any), ...defaultRules]"
lazy-validation
@blur="emitBlur"
/>
<!-- Option Select -->
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
:label="inputField.label"
:name="inputField.varName"
:items="inputField.options"
item-title="text"
item-value="text"
:return-object="false"
:hint="inputField.hint"
density="comfortable"
persistent-hint
lazy-validation
@blur="emitBlur"
/>
<!-- Color Picker -->
<div
v-else-if="inputField.type === fieldTypes.COLOR"
class="d-flex"
style="width: 100%"
>
<v-menu offset-y>
<template #activator="{ props: templateProps }">
<v-btn
class="my-2 ml-auto"
style="min-width: 200px"
:color="model[inputField.varName]"
dark
v-bind="templateProps"
>
{{ inputField.label }}
</v-btn>
</template>
<v-color-picker
v-model="model[inputField.varName]"
value="#7417BE"
hide-canvas
hide-inputs
show-swatches
class="mx-auto"
@input="emitBlur"
/>
</v-menu>
</div>
<!-- Object Type -->
<div v-else-if="inputField.type === fieldTypes.OBJECT">
<auto-form
v-model="model[inputField.varName]"
:color="color"
:items="(inputField as any).items"
@blur="emitBlur"
<v-divider
v-if="inputField.section"
class="my-2"
/>
</div>
<!-- List Type -->
<div v-else-if="inputField.type === fieldTypes.LIST">
<div
v-for="(item, idx) in model[inputField.varName]"
:key="idx"
<v-card-title
v-if="inputField.section"
class="pl-0"
>
<p>
{{ inputField.label }} {{ idx + 1 }}
<span>
<BaseButton
class="ml-5"
x-small
delete
@click="removeByIndex(model[inputField.varName], idx)"
/>
{{ inputField.section }}
</v-card-title>
<v-card-text
v-if="inputField.sectionDetails"
class="pl-0 mt-0 pt-0"
>
{{ inputField.sectionDetails }}
</v-card-text>
<!-- Check Box -->
<v-checkbox
v-if="inputField.type === fieldTypes.BOOLEAN"
v-model="model[inputField.varName]"
:name="inputField.varName"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:hint="inputField.hint"
:hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint"
density="comfortable"
validate-on="input"
>
<template #label>
<span class="ml-4">
{{ inputField.label }}
</span>
</p>
<v-divider class="mb-5 mx-2" />
<auto-form
v-model="model[inputField.varName][idx]"
:color="color"
:items="(inputField as any).items"
@blur="emitBlur"
/>
</template>
</v-checkbox>
<!-- Text Field -->
<v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
variant="solo-filled"
flat
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
validate-on="input"
/>
<!-- Text Area -->
<v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
rows="3"
auto-grow
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
validate-on="input"
/>
<!-- Option Select -->
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
:label="inputField.label"
:name="inputField.varName"
:items="inputField.options"
item-title="text"
:item-value="inputField.selectReturnValue || 'text'"
:return-object="false"
:hint="inputField.hint"
density="comfortable"
persistent-hint
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
validate-on="input"
/>
<!-- Color Picker -->
<div
v-else-if="inputField.type === fieldTypes.COLOR"
class="d-flex"
style="width: 100%"
>
<InputColor v-model="model[inputField.varName]" />
</div>
<v-card-actions>
<v-spacer />
<BaseButton
small
@click="model[inputField.varName].push(getTemplate((inputField as any).items))"
>
{{ $t("general.new") }}
</BaseButton>
</v-card-actions>
</div>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
</v-card>
</v-form>
</template>
<script lang="ts" setup>
import { validators } from "@/composables/use-validators";
import { fieldTypes } from "@/composables/forms";
import type { AutoFormItems } from "~/types/auto-forms";
const BLUR_EVENT = "blur";
type ValidatorKey = keyof typeof validators;
// Use defineModel for v-model
const modelValue = defineModel<Record<string, any> | any[]>({
const model = defineModel<Record<string, any> | any[]>({
type: [Object, Array],
required: true,
});
// alias to avoid template TS complaining about possible undefined
const model = modelValue as any;
const isValid = defineModel("isValid", { type: Boolean, default: false });
const props = defineProps({
updateMode: {
@@ -220,10 +145,6 @@ const props = defineProps({
type: [Number, String],
default: "max",
},
globalRules: {
default: null,
type: Array as () => string[],
},
color: {
default: null,
type: String,
@@ -242,31 +163,6 @@ const props = defineProps({
},
});
const emit = defineEmits(["blur", "update:modelValue"]);
function rulesByKey(keys?: ValidatorKey[] | null) {
if (keys === undefined || keys === null) {
return [] as any[];
}
const list: any[] = [];
keys.forEach((key) => {
const split = key.split(":");
const validatorKey = split[0] as ValidatorKey;
if (validatorKey in validators) {
if (split.length === 1) {
list.push((validators as any)[validatorKey]);
}
else {
list.push((validators as any)[validatorKey](split[1] as any));
}
}
});
return list;
}
const defaultRules = computed<any[]>(() => rulesByKey(props.globalRules as any));
// Combined state map for readonly and disabled fields
const fieldState = computed<Record<string, { readonly: boolean; disabled: boolean }>>(() => {
const map: Record<string, { readonly: boolean; disabled: boolean }> = {};
@@ -279,25 +175,6 @@ const fieldState = computed<Record<string, { readonly: boolean; disabled: boolea
});
return map;
});
function removeByIndex(list: never[], index: number) {
// Removes the item at the index
list.splice(index, 1);
}
function getTemplate(item: AutoFormItems) {
const obj = {} as { [key: string]: string };
item.forEach((field) => {
obj[field.varName] = "";
});
return obj;
}
function emitBlur() {
emit(BLUR_EVENT, modelValue.value);
}
</script>
<style lang="scss" scoped></style>

View File

@@ -8,11 +8,11 @@
nudge-bottom="6"
:close-on-content-click="false"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
color="accent"
variant="elevated"
v-bind="props"
v-bind="activatorProps"
>
<v-icon>
{{ $globals.icons.cog }}
@@ -108,7 +108,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { downloadAsJson } from "~/composables/use-utils";
export interface TableConfig {
@@ -120,7 +120,7 @@ export interface TableHeaders {
text: string;
value: string;
show: boolean;
align?: string;
align?: "start" | "center" | "end";
sortable?: boolean;
sort?: (a: any, b: any) => number;
}
@@ -131,106 +131,95 @@ export interface BulkAction {
event: string;
}
export default defineNuxtComponent({
props: {
tableConfig: {
type: Object as () => TableConfig,
default: () => ({
hideColumns: false,
canExport: false,
}),
},
headers: {
type: Array as () => TableHeaders[],
required: true,
},
data: {
type: Array as () => any[],
required: true,
},
bulkActions: {
type: Array as () => BulkAction[],
default: () => [],
},
initialSort: {
type: String,
default: "id",
},
initialSortDesc: {
type: Boolean,
default: false,
},
const props = defineProps({
tableConfig: {
type: Object as () => TableConfig,
default: () => ({
hideColumns: false,
canExport: false,
}),
},
emits: ["delete-one", "edit-one"],
setup(props, context) {
const i18n = useI18n();
const sortBy = computed(() => [{
key: props.initialSort,
order: props.initialSortDesc ? "desc" : "asc",
}]);
// ===========================================================
// Reactive Headers
// Create a local reactive copy of headers that we can modify
const localHeaders = ref([...props.headers]);
// Watch for changes in props.headers and update local copy
watch(() => props.headers, (newHeaders) => {
localHeaders.value = [...newHeaders];
}, { deep: true });
const filteredHeaders = computed<string[]>(() => {
return localHeaders.value.filter(header => header.show).map(header => header.value);
});
const headersWithoutActions = computed(() =>
localHeaders.value
.filter(header => filteredHeaders.value.includes(header.value))
.map(header => ({
...header,
title: i18n.t(header.text),
})),
);
const activeHeaders = computed(() => [
...headersWithoutActions.value,
{ title: "", value: "actions", show: true, align: "end" },
]);
const selected = ref<any[]>([]);
// ===========================================================
// Bulk Action Event Handler
const bulkActionListener = computed(() => {
const handlers: { [key: string]: () => void } = {};
props.bulkActions.forEach((action) => {
handlers[action.event] = () => {
context.emit(action.event, selected.value);
// clear selection
selected.value = [];
};
});
return handlers;
});
const search = ref("");
return {
sortBy,
selected,
localHeaders,
filteredHeaders,
headersWithoutActions,
activeHeaders,
bulkActionListener,
search,
downloadAsJson,
};
headers: {
type: Array as () => TableHeaders[],
required: true,
},
data: {
type: Array as () => any[],
required: true,
},
bulkActions: {
type: Array as () => BulkAction[],
default: () => [],
},
initialSort: {
type: String,
default: "id",
},
initialSortDesc: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{
(e: "delete-one" | "edit-one", item: any): void;
(e: "bulk-action", event: string, items: any[]): void;
}>();
const i18n = useI18n();
const sortBy = computed<{ key: string; order: "asc" | "desc" }[]>(() => [{
key: props.initialSort,
order: props.initialSortDesc ? "desc" : "asc",
}]);
// ===========================================================
// Reactive Headers
// Create a local reactive copy of headers that we can modify
const localHeaders = ref([...props.headers]);
// Watch for changes in props.headers and update local copy
watch(() => props.headers, (newHeaders) => {
localHeaders.value = [...newHeaders];
}, { deep: true });
const filteredHeaders = computed<string[]>(() => {
return localHeaders.value.filter(header => header.show).map(header => header.value);
});
const headersWithoutActions = computed(() =>
localHeaders.value
.filter(header => filteredHeaders.value.includes(header.value))
.map(header => ({
...header,
title: i18n.t(header.text),
})),
);
const activeHeaders = computed(() => [
...headersWithoutActions.value,
{ title: "", value: "actions", show: true, align: "end" },
]);
const selected = ref<any[]>([]);
// ===========================================================
// Bulk Action Event Handler
const bulkActionListener = computed(() => {
const handlers: { [key: string]: () => void } = {};
props.bulkActions.forEach((action) => {
handlers[action.event] = () => {
emit("bulk-action", action.event, selected.value);
// clear selection
selected.value = [];
};
});
return handlers;
});
const search = ref("");
</script>
<style>

View File

@@ -1,10 +1,8 @@
export const fieldTypes = {
TEXT: "text",
TEXT_AREA: "textarea",
LIST: "list",
SELECT: "select",
OBJECT: "object",
BOOLEAN: "boolean",
COLOR: "color",
PASSWORD: "password",
COLOR: "color",
} as const;

View File

@@ -1,4 +1,5 @@
import { fieldTypes } from "../forms";
import { validators } from "../use-validators";
import type { AutoFormItems } from "~/types/auto-forms";
export const useCommonSettingsForm = () => {
@@ -11,7 +12,7 @@ export const useCommonSettingsForm = () => {
hint: i18n.t("group.enable-public-access-description"),
varName: "makeGroupRecipesPublic",
type: fieldTypes.BOOLEAN,
rules: ["required"],
rules: [validators.required],
},
{
section: i18n.t("data-pages.data-management"),
@@ -19,7 +20,7 @@ export const useCommonSettingsForm = () => {
hint: i18n.t("user-registration.use-seed-data-description"),
varName: "useSeedData",
type: fieldTypes.BOOLEAN,
rules: ["required"],
rules: [validators.required],
},
]);

View File

@@ -1,4 +1,5 @@
import { fieldTypes } from "../forms";
import { validators } from "../use-validators";
import type { AutoFormItems } from "~/types/auto-forms";
export const useUserForm = () => {
@@ -10,26 +11,26 @@ export const useUserForm = () => {
label: i18n.t("user.user-name"),
varName: "username",
type: fieldTypes.TEXT,
rules: ["required"],
rules: [validators.required],
},
{
label: i18n.t("user.full-name"),
varName: "fullName",
type: fieldTypes.TEXT,
rules: ["required"],
rules: [validators.required],
},
{
label: i18n.t("user.email"),
varName: "email",
type: fieldTypes.TEXT,
rules: ["required"],
rules: [validators.required],
},
{
label: i18n.t("user.password"),
varName: "password",
disableUpdate: true,
type: fieldTypes.PASSWORD,
rules: ["required", "minLength:8"],
rules: [validators.required, validators.minLength(8)],
},
{
label: i18n.t("user.authentication-method"),
@@ -44,37 +45,37 @@ export const useUserForm = () => {
label: i18n.t("user.administrator"),
varName: "admin",
type: fieldTypes.BOOLEAN,
rules: ["required"],
rules: [validators.required],
},
{
label: i18n.t("user.user-can-invite-other-to-group"),
varName: "canInvite",
type: fieldTypes.BOOLEAN,
rules: ["required"],
rules: [validators.required],
},
{
label: i18n.t("user.user-can-manage-group"),
varName: "canManage",
type: fieldTypes.BOOLEAN,
rules: ["required"],
rules: [validators.required],
},
{
label: i18n.t("user.user-can-organize-group-data"),
varName: "canOrganize",
type: fieldTypes.BOOLEAN,
rules: ["required"],
rules: [validators.required],
},
{
label: i18n.t("user.user-can-manage-household"),
varName: "canManageHousehold",
type: fieldTypes.BOOLEAN,
rules: ["required"],
rules: [validators.required],
},
{
label: i18n.t("user.enable-advanced-features"),
varName: "advanced",
type: fieldTypes.BOOLEAN,
rules: ["required"],
rules: [validators.required],
},
];

View File

@@ -13,10 +13,10 @@ export const validators = {
};
/**
* useAsyncValidator us a factory function that returns an async function that
* when called will validate the input against the backend database and set the
* error messages when applicable to the ref.
*/
* useAsyncValidator us a factory function that returns an async function that
* when called will validate the input against the backend database and set the
* error messages when applicable to the ref.
*/
export const useAsyncValidator = (
value: Ref<string>,
validatorFunc: (v: string) => Promise<RequestResponse<ValidationResponse>>,

View File

@@ -1459,6 +1459,6 @@
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -1423,8 +1423,8 @@
"is-greater-than-or-equal-to": "jest większe lub równe",
"is-less-than": "jest mniejsze niż",
"is-less-than-or-equal-to": "jest mniejsze lub równe",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
"is-older-than": "jest starsze niż",
"is-newer-than": "jest nowsze niż"
},
"relational-keywords": {
"is": "jest",
@@ -1436,7 +1436,7 @@
"is-not-like": "nie jest jak"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
"days-ago": "dni temu|dzień temu|dni temu"
}
},
"validators": {

View File

@@ -212,8 +212,8 @@
"upload-file": "Enviar arquivo",
"created-on-date": "Criado em {0}",
"unsaved-changes": "Você possui alterações não salvas. Deseja salvar antes de sair? Ok para salvar, Cancelar para descartar alterações.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"discard-changes": "Descartar alterações",
"discard-changes-description": "Você tem alterações não salvas. Tem certeza de que deseja descartá-las?",
"clipboard-copy-failure": "Falha ao copiar para a área de transferência.",
"confirm-delete-generic-items": "Tem certeza que quer excluir os itens seguintes?",
"organizers": "Organizadores",
@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Site sendo bloqueado?",
"scrape-recipe-try-importing-raw-html-instead": "Tente importar o HTML ao invés disso.",
"import-original-keywords-as-tags": "Importar palavras-chave originais como marcadores",
"import-original-categories": "Import original categories",
"import-original-categories": "Importar categorias originais",
"stay-in-edit-mode": "Permanecer no modo de edição",
"parse-recipe-ingredients-after-import": "Interpretar os ingredientes da receita após importar",
"import-from-zip": "Importar do .zip",
@@ -1423,7 +1423,7 @@
"is-greater-than-or-equal-to": "é maior ou igual a",
"is-less-than": "é menor que",
"is-less-than-or-equal-to": "é menor ou igual a",
"is-older-than": "is older than",
"is-older-than": "Mais antigo que",
"is-newer-than": "is newer than"
},
"relational-keywords": {

View File

@@ -22,7 +22,7 @@ export function whitespace(v: string | null | undefined) {
export function url(v: string | undefined | null) {
const i18n = useGlobalI18n();
return (!!v && URL_REGEX.test(v)) || i18n.t("validators.invalid-url");
return (!!v && URL_REGEX.test(v) && (v.startsWith("http://") || v.startsWith("https://"))) || i18n.t("validators.invalid-url");
}
export function urlOptional(v: string | undefined | null) {

View File

@@ -95,6 +95,7 @@
<script lang="ts">
import { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups";
import { validators } from "~/composables/use-validators";
import type { GroupInDB } from "~/lib/api/types/user";
export default defineNuxtComponent({
@@ -136,7 +137,7 @@ export default defineNuxtComponent({
label: i18n.t("group.group-name"),
varName: "name",
type: fieldTypes.TEXT,
rules: ["required"],
rules: [validators.required],
},
],
data: {

View File

@@ -160,7 +160,7 @@ const createHouseholdForm = reactive({
label: i18n.t("household.household-name"),
varName: "name",
type: fieldTypes.TEXT,
rules: ["required"],
rules: [validators.required],
},
],
data: {

View File

@@ -1,245 +1,91 @@
<template>
<div>
<!-- Create Dialog -->
<BaseDialog
v-model="state.createDialog"
:title="$t('data-pages.categories.new-category')"
<GroupDataPage
:icon="$globals.icons.categories"
can-submit
@submit="createCategory"
>
<v-card-text>
<v-form ref="domNewCategoryForm">
<v-text-field
v-model="createTarget.name"
autofocus
:label="$t('general.name')"
:rules="[validators.required]"
/>
</v-form>
</v-card-text>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
v-model="state.editDialog"
:icon="$globals.icons.categories"
:title="$t('data-pages.categories.edit-category')"
:submit-text="$t('general.save')"
can-submit
@submit="editSaveCategory"
>
<v-card-text v-if="editTarget">
<div class="mt-4">
<v-text-field
v-model="editTarget.name"
:label="$t('general.name')"
/>
</div>
</v-card-text>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="state.deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteCategory"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p
v-if="deleteTarget"
class="mt-4 ml-4"
>
{{ deleteTarget.name }}
</p>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="state.bulkDeleteDialog"
width="650px"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteSelected"
>
<v-card-text>
<p class="h4">
{{ $t('general.confirm-delete-generic-items') }}
</p>
<v-card variant="outlined">
<v-virtual-scroll
height="400"
item-height="25"
:items="bulkDeleteTarget"
>
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<!-- Data Table -->
<BaseCardSectionTitle
:icon="$globals.icons.categories"
section
:title="$t('data-pages.categories.category-data')"
/>
<CrudTable
v-model:headers="tableHeaders"
:table-headers="tableHeaders"
:table-config="tableConfig"
:data="categories || []"
:data="categoryStore.store.value || []"
:bulk-actions="[{ icon: $globals.icons.delete, text: $t('general.delete'), event: 'delete-selected' }]"
initial-sort="name"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
>
<template #button-row>
<BaseButton
create
@click="state.createDialog = true"
>
{{ $t("general.create") }}
</BaseButton>
</template>
</CrudTable>
:create-form="createForm"
:edit-form="editForm"
@create-one="handleCreate"
@edit-one="handleEdit"
@delete-one="categoryStore.actions.deleteOne"
@bulk-action="handleBulkAction"
/>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useCategoryStore } from "~/composables/store";
import { validators } from "~/composables/use-validators";
import { useCategoryStore, useCategoryData } from "~/composables/store";
import { fieldTypes } from "~/composables/forms";
import type { AutoFormItems } from "~/types/auto-forms";
import type { RecipeCategory } from "~/lib/api/types/recipe";
import type { TableHeaders, TableConfig } from "~/components/global/CrudTable.vue";
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const tableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
];
const state = reactive({
createDialog: false,
editDialog: false,
deleteDialog: false,
bulkDeleteDialog: false,
});
const categoryData = useCategoryData();
const categoryStore = useCategoryStore();
// ============================================================
// Create Category
async function createCategory() {
await categoryStore.actions.createOne({
name: categoryData.data.name,
slug: "",
});
categoryData.reset();
state.createDialog = false;
}
// ============================================================
// Edit Category
const editTarget = ref<RecipeCategory | null>(null);
function editEventHandler(item: RecipeCategory) {
state.editDialog = true;
editTarget.value = item;
}
async function editSaveCategory() {
if (!editTarget.value) {
return;
}
await categoryStore.actions.updateOne(editTarget.value);
state.editDialog = false;
}
// ============================================================
// Delete Category
const deleteTarget = ref<RecipeCategory | null>(null);
function deleteEventHandler(item: RecipeCategory) {
state.deleteDialog = true;
deleteTarget.value = item;
}
async function deleteCategory() {
if (!deleteTarget.value || deleteTarget.value.id === undefined) {
return;
}
await categoryStore.actions.deleteOne(deleteTarget.value.id);
state.deleteDialog = false;
}
// ============================================================
// Bulk Delete Category
const bulkDeleteTarget = ref<RecipeCategory[]>([]);
function bulkDeleteEventHandler(selection: RecipeCategory[]) {
bulkDeleteTarget.value = selection;
state.bulkDeleteDialog = true;
}
async function deleteSelected() {
const ids = bulkDeleteTarget.value.map(item => item.id).filter(id => !!id);
await categoryStore.actions.deleteMany(ids);
bulkDeleteTarget.value = [];
}
return {
state,
tableConfig,
tableHeaders,
categories: categoryStore.store,
validators,
// create
createTarget: categoryData.data,
createCategory,
// edit
editTarget,
editEventHandler,
editSaveCategory,
// delete
deleteTarget,
deleteEventHandler,
deleteCategory,
// bulk delete
bulkDeleteTarget,
bulkDeleteEventHandler,
deleteSelected,
};
const i18n = useI18n();
const tableConfig: TableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders: TableHeaders[] = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
];
const categoryStore = useCategoryStore();
// ============================================================
// Form items (shared)
const formItems = [
{
label: i18n.t("general.name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
] as AutoFormItems;
// ============================================================
// Create
const createForm = reactive({
items: formItems,
data: { name: "" } as RecipeCategory,
});
async function handleCreate(createFormData: RecipeCategory) {
await categoryStore.actions.createOne(createFormData);
createForm.data.name = "";
}
// ============================================================
// Edit
const editForm = reactive({
items: formItems,
data: {} as RecipeCategory,
});
async function handleEdit(editFormData: RecipeCategory) {
await categoryStore.actions.updateOne(editFormData);
editForm.data = {} as RecipeCategory;
}
// ============================================================
// Bulk Actions
async function handleBulkAction(event: string, items: RecipeCategory[]) {
if (event === "delete-selected") {
const ids = items.filter(item => item.id != null).map(item => item.id!);
await categoryStore.actions.deleteMany(ids);
}
}
</script>

View File

@@ -79,170 +79,15 @@
</v-card-text>
</BaseDialog>
<!-- Create Dialog -->
<BaseDialog
v-model="createDialog"
:icon="$globals.icons.foods"
:title="$t('data-pages.foods.create-food')"
:submit-icon="$globals.icons.save"
:submit-text="$t('general.save')"
can-submit
@submit="createFood"
>
<v-card-text>
<v-form ref="domNewFoodForm">
<v-text-field
v-model="createTarget.name"
autofocus
:label="$t('general.name')"
:hint="$t('data-pages.foods.example-food-singular')"
:rules="[validators.required]"
/>
<v-text-field
v-model="createTarget.pluralName"
:label="$t('general.plural-name')"
:hint="$t('data-pages.foods.example-food-plural')"
/>
<v-text-field
v-model="createTarget.description"
:label="$t('recipe.description')"
/>
<v-autocomplete
v-model="createTarget.labelId"
clearable
:items="allLabels"
:custom-filter="normalizeFilter"
item-value="id"
item-title="name"
:label="$t('data-pages.foods.food-label')"
/>
<v-checkbox
v-model="createTarget.onHand"
hide-details
:label="$t('tool.on-hand')"
/>
<p class="text-caption mt-1">
{{ $t("data-pages.foods.on-hand-checkbox-label") }}
</p>
</v-form>
</v-card-text>
</BaseDialog>
<!-- Alias Sub-Dialog -->
<RecipeDataAliasManagerDialog
v-if="editTarget"
v-if="editForm.data"
v-model="aliasManagerDialog"
:data="editTarget"
:data="editForm.data"
@submit="updateFoodAlias"
@cancel="aliasManagerDialog = false"
/>
<!-- Edit Dialog -->
<BaseDialog
v-model="editDialog"
:icon="$globals.icons.foods"
:title="$t('data-pages.foods.edit-food')"
:submit-icon="$globals.icons.save"
:submit-text="$t('general.save')"
can-submit
@submit="editSaveFood"
>
<v-card-text v-if="editTarget">
<v-form ref="domEditFoodForm">
<v-text-field
v-model="editTarget.name"
:label="$t('general.name')"
:hint="$t('data-pages.foods.example-food-singular')"
:rules="[validators.required]"
/>
<v-text-field
v-model="editTarget.pluralName"
:label="$t('general.plural-name')"
:hint="$t('data-pages.foods.example-food-plural')"
/>
<v-text-field
v-model="editTarget.description"
:label="$t('recipe.description')"
/>
<v-autocomplete
v-model="editTarget.labelId"
clearable
:items="allLabels"
:custom-filter="normalizeFilter"
item-value="id"
item-title="name"
:label="$t('data-pages.foods.food-label')"
/>
<v-checkbox
v-model="editTarget.onHand"
hide-details
:label="$t('tool.on-hand')"
/>
<p class="text-caption mt-1">
{{ $t("data-pages.foods.on-hand-checkbox-label") }}
</p>
</v-form>
</v-card-text>
<template #custom-card-action>
<BaseButton
edit
@click="aliasManagerEventHandler"
>
{{ $t('data-pages.manage-aliases') }}
</BaseButton>
</template>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteFood"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p
v-if="deleteTarget"
class="mt-4 ml-4"
>
{{ deleteTarget.name }}
</p>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="bulkDeleteDialog"
width="650px"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteSelected"
>
<v-card-text>
<p class="h4">
{{ $t('general.confirm-delete-generic-items') }}
</p>
<v-card variant="outlined">
<v-virtual-scroll
height="400"
item-height="25"
:items="bulkDeleteTarget"
>
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<!-- Bulk Assign Labels Dialog -->
<BaseDialog
v-model="bulkAssignLabelDialog"
@@ -282,33 +127,24 @@
</v-card-text>
</BaseDialog>
<!-- Data Table -->
<BaseCardSectionTitle
<GroupDataPage
:icon="$globals.icons.foods"
section
:title="$t('data-pages.foods.food-data')"
/>
<CrudTable
v-model:headers="tableHeaders"
:table-headers="tableHeaders"
:table-config="tableConfig"
:data="foods || []"
:bulk-actions="[
{ icon: $globals.icons.delete, text: $t('general.delete'), event: 'delete-selected' },
{ icon: $globals.icons.tags, text: $t('data-pages.labels.assign-label'), event: 'assign-selected' },
]"
initial-sort="createdAt"
initial-sort-desc
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@create-one="createEventHandler"
@delete-selected="bulkDeleteEventHandler"
@assign-selected="bulkAssignEventHandler"
:create-form="createForm"
:edit-form="editForm"
@create-one="handleCreate"
@edit-one="handleEdit"
@delete-one="foodStore.actions.deleteOne"
@bulk-action="handleBulkAction"
>
<template #button-row>
<BaseButton
create
@click="createDialog = true"
/>
<template #table-button-row>
<BaseButton @click="mergeDialog = true">
<template #icon>
{{ $globals.icons.externalLink }}
@@ -316,6 +152,7 @@
{{ $t('data-pages.combine') }}
</BaseButton>
</template>
<template #[`item.label`]="{ item }">
<MultiPurposeLabel
v-if="item.label"
@@ -324,15 +161,18 @@
{{ item.label.name }}
</MultiPurposeLabel>
</template>
<template #[`item.onHand`]="{ item }">
<v-icon :color="item.onHand ? 'success' : undefined">
{{ item.onHand ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
<template #[`item.createdAt`]="{ item }">
{{ item.createdAt ? $d(new Date(item.createdAt)) : '' }}
</template>
<template #button-bottom>
<template #table-button-bottom>
<BaseButton @click="seedDialog = true">
<template #icon>
{{ $globals.icons.database }}
@@ -340,11 +180,20 @@
{{ $t('data-pages.seed') }}
</BaseButton>
</template>
</CrudTable>
<template #edit-dialog-custom-action>
<BaseButton
edit
@click="aliasManagerDialog = true"
>
{{ $t('data-pages.manage-aliases') }}
</BaseButton>
</template>
</GroupDataPage>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { LocaleObject } from "@nuxtjs/i18n";
import RecipeDataAliasManagerDialog from "~/components/Domain/Recipe/RecipeDataAliasManagerDialog.vue";
import { validators } from "~/composables/use-validators";
@@ -355,7 +204,9 @@ import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
import { useFoodStore, useLabelStore } from "~/composables/store";
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import type { VForm } from "~/types/auto-forms";
import type { AutoFormItems } from "~/types/auto-forms";
import type { TableHeaders, TableConfig } from "~/components/global/CrudTable.vue";
import { fieldTypes } from "~/composables/forms";
interface CreateIngredientFoodWithOnHand extends CreateIngredientFood {
onHand: boolean;
@@ -365,315 +216,256 @@ interface CreateIngredientFoodWithOnHand extends CreateIngredientFood {
interface IngredientFoodWithOnHand extends IngredientFood {
onHand: boolean;
}
export default defineNuxtComponent({
components: { MultiPurposeLabel, RecipeDataAliasManagerDialog },
setup() {
const userApi = useUserApi();
const i18n = useI18n();
const auth = useMealieAuth();
const tableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
{
text: i18n.t("general.plural-name"),
value: "pluralName",
show: true,
sortable: true,
},
{
text: i18n.t("recipe.description"),
value: "description",
show: true,
},
{
text: i18n.t("shopping-list.label"),
value: "label",
show: true,
sortable: true,
sort: (label1: MultiPurposeLabelOut | null, label2: MultiPurposeLabelOut | null) => {
const label1Name = label1?.name || "";
const label2Name = label2?.name || "";
return label1Name.localeCompare(label2Name);
},
},
{
text: i18n.t("tool.on-hand"),
value: "onHand",
show: true,
sortable: true,
},
{
text: i18n.t("general.date-added"),
value: "createdAt",
show: false,
sortable: true,
},
];
const userHousehold = computed(() => auth.user.value?.householdSlug || "");
const foodStore = useFoodStore();
const foods = computed(() => foodStore.store.value.map((food) => {
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
return { ...food, onHand } as IngredientFoodWithOnHand;
}));
// ===============================================================
// Food Creator
const domNewFoodForm = ref<VForm>();
const createDialog = ref(false);
const createTarget = ref<CreateIngredientFoodWithOnHand>({
name: "",
onHand: false,
householdsWithIngredientFood: [],
});
function createEventHandler() {
createDialog.value = true;
}
async function createFood() {
if (!createTarget.value || !createTarget.value.name) {
return;
}
if (createTarget.value.onHand) {
createTarget.value.householdsWithIngredientFood = [userHousehold.value];
}
// @ts-expect-error the createOne function erroneously expects an id because it uses the IngredientFood type
await foodStore.actions.createOne(createTarget.value);
createDialog.value = false;
domNewFoodForm.value?.reset();
createTarget.value = {
name: "",
onHand: false,
householdsWithIngredientFood: [],
};
}
// ===============================================================
// Food Editor
const editDialog = ref(false);
const editTarget = ref<IngredientFoodWithOnHand | null>(null);
function editEventHandler(item: IngredientFoodWithOnHand) {
editTarget.value = item;
editTarget.value.onHand = item.householdsWithIngredientFood?.includes(userHousehold.value) || false;
editDialog.value = true;
}
async function editSaveFood() {
if (!editTarget.value) {
return;
}
if (editTarget.value.onHand && !editTarget.value.householdsWithIngredientFood?.includes(userHousehold.value)) {
if (!editTarget.value.householdsWithIngredientFood) {
editTarget.value.householdsWithIngredientFood = [userHousehold.value];
}
else {
editTarget.value.householdsWithIngredientFood.push(userHousehold.value);
}
}
else if (!editTarget.value.onHand && editTarget.value.householdsWithIngredientFood?.includes(userHousehold.value)) {
editTarget.value.householdsWithIngredientFood = editTarget.value.householdsWithIngredientFood.filter(
household => household !== userHousehold.value,
);
}
await foodStore.actions.updateOne(editTarget.value);
editDialog.value = false;
}
// ===============================================================
// Food Delete
const deleteDialog = ref(false);
const deleteTarget = ref<IngredientFoodWithOnHand | null>(null);
function deleteEventHandler(item: IngredientFoodWithOnHand) {
deleteTarget.value = item;
deleteDialog.value = true;
}
async function deleteFood() {
if (!deleteTarget.value) {
return;
}
await foodStore.actions.deleteOne(deleteTarget.value.id);
deleteDialog.value = false;
}
const bulkDeleteDialog = ref(false);
const bulkDeleteTarget = ref<IngredientFoodWithOnHand[]>([]);
function bulkDeleteEventHandler(selection: IngredientFoodWithOnHand[]) {
bulkDeleteTarget.value = selection;
bulkDeleteDialog.value = true;
}
async function deleteSelected() {
const ids = bulkDeleteTarget.value.map(item => item.id);
await foodStore.actions.deleteMany(ids);
bulkDeleteTarget.value = [];
}
// ============================================================
// Alias Manager
const aliasManagerDialog = ref(false);
function aliasManagerEventHandler() {
aliasManagerDialog.value = true;
}
function updateFoodAlias(newAliases: IngredientFoodAlias[]) {
if (!editTarget.value) {
return;
}
editTarget.value.aliases = newAliases;
aliasManagerDialog.value = false;
}
// ============================================================
// Merge Foods
const mergeDialog = ref(false);
const fromFood = ref<IngredientFoodWithOnHand | null>(null);
const toFood = ref<IngredientFoodWithOnHand | null>(null);
const canMerge = computed(() => {
return fromFood.value && toFood.value && fromFood.value.id !== toFood.value.id;
});
async function mergeFoods() {
if (!canMerge.value || !fromFood.value || !toFood.value) {
return;
}
const { data } = await userApi.foods.merge(fromFood.value.id, toFood.value.id);
if (data) {
foodStore.actions.refresh();
}
}
// ============================================================
// Labels
const { store: allLabels } = useLabelStore();
// ============================================================
// Seed
const seedDialog = ref(false);
const locale = ref("");
const { locales: LOCALES, locale: currentLocale } = useLocales();
onMounted(() => {
locale.value = currentLocale.value;
});
const locales = LOCALES.filter(locale =>
(i18n.locales.value as LocaleObject[]).map(i18nLocale => i18nLocale.code).includes(locale.value as any),
);
async function seedDatabase() {
const { data } = await userApi.seeders.foods({ locale: locale.value });
if (data) {
foodStore.actions.refresh();
}
}
// ============================================================
// Bulk Assign Labels
const bulkAssignLabelDialog = ref(false);
const bulkAssignTarget = ref<IngredientFoodWithOnHand[]>([]);
const bulkAssignLabelId = ref<string | undefined>();
function bulkAssignEventHandler(selection: IngredientFoodWithOnHand[]) {
bulkAssignTarget.value = selection;
bulkAssignLabelDialog.value = true;
}
async function assignSelected() {
if (!bulkAssignLabelId.value) {
return;
}
for (const item of bulkAssignTarget.value) {
item.labelId = bulkAssignLabelId.value;
await foodStore.actions.updateOne(item);
}
bulkAssignTarget.value = [];
bulkAssignLabelId.value = undefined;
foodStore.actions.refresh();
}
return {
tableConfig,
tableHeaders,
foods,
allLabels,
validators,
normalizeFilter,
// Create
createDialog,
domNewFoodForm,
createEventHandler,
createFood,
createTarget,
// Edit
editDialog,
editEventHandler,
editSaveFood,
editTarget,
// Delete
deleteEventHandler,
deleteDialog,
deleteFood,
deleteTarget,
bulkDeleteDialog,
bulkDeleteTarget,
bulkDeleteEventHandler,
deleteSelected,
// Alias Manager
aliasManagerDialog,
aliasManagerEventHandler,
updateFoodAlias,
// Merge
canMerge,
mergeFoods,
mergeDialog,
fromFood,
toFood,
// Seed Data
locale,
locales,
seedDialog,
seedDatabase,
// Bulk Assign Labels
bulkAssignLabelDialog,
bulkAssignTarget,
bulkAssignLabelId,
bulkAssignEventHandler,
assignSelected,
};
const userApi = useUserApi();
const i18n = useI18n();
const auth = useMealieAuth();
const tableConfig: TableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders: TableHeaders[] = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
{
text: i18n.t("general.plural-name"),
value: "pluralName",
show: true,
sortable: true,
},
{
text: i18n.t("recipe.description"),
value: "description",
show: true,
},
{
text: i18n.t("shopping-list.label"),
value: "label",
show: true,
sortable: true,
sort: (label1: MultiPurposeLabelOut | null, label2: MultiPurposeLabelOut | null) => {
const label1Name = label1?.name || "";
const label2Name = label2?.name || "";
return label1Name.localeCompare(label2Name);
},
},
{
text: i18n.t("tool.on-hand"),
value: "onHand",
show: true,
sortable: true,
},
{
text: i18n.t("general.date-added"),
value: "createdAt",
show: false,
sortable: true,
},
];
const userHousehold = computed(() => auth.user.value?.householdSlug || "");
const foodStore = useFoodStore();
const foods = computed(() => foodStore.store.value.map((food) => {
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
return { ...food, onHand } as IngredientFoodWithOnHand;
}));
// ============================================================
// Labels
const { store: allLabels } = useLabelStore();
const labelOptions = computed(() => allLabels.value.map(label => ({ text: label.name, value: label.id })) || []);
// ============================================================
// Form items (shared)
const formItems = computed<AutoFormItems>(() => [
{
label: i18n.t("general.name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
{
label: i18n.t("general.plural-name"),
varName: "pluralName",
type: fieldTypes.TEXT,
},
{
label: i18n.t("recipe.description"),
varName: "description",
type: fieldTypes.TEXT,
},
{
label: i18n.t("data-pages.foods.food-label"),
varName: "labelId",
type: fieldTypes.SELECT,
options: labelOptions.value,
},
{
label: i18n.t("tool.on-hand"),
varName: "onHand",
type: fieldTypes.BOOLEAN,
hint: i18n.t("data-pages.foods.on-hand-checkbox-label"),
},
]);
// ===============================================================
// Create
const createForm = reactive({
get items() {
return formItems.value;
},
data: { name: "", onHand: false, householdsWithIngredientFood: [] } as CreateIngredientFoodWithOnHand,
});
async function handleCreate() {
if (!createForm.data || !createForm.data.name) {
return;
}
if (createForm.data.onHand) {
createForm.data.householdsWithIngredientFood = [userHousehold.value];
}
// @ts-expect-error the createOne function erroneously expects an id because it uses the IngredientFood type
await foodStore.actions.createOne(createForm.data);
createForm.data = {
name: "",
onHand: false,
householdsWithIngredientFood: [],
};
}
// ===============================================================
// Edit
const editForm = reactive({
get items() {
return formItems.value;
},
data: {} as IngredientFoodWithOnHand,
});
async function handleEdit() {
if (!editForm.data) {
return;
}
if (!editForm.data.householdsWithIngredientFood) {
editForm.data.householdsWithIngredientFood = [];
}
if (editForm.data.onHand && !editForm.data.householdsWithIngredientFood.includes(userHousehold.value)) {
editForm.data.householdsWithIngredientFood.push(userHousehold.value);
}
else if (!editForm.data.onHand && editForm.data.householdsWithIngredientFood.includes(userHousehold.value)) {
const idx = editForm.data.householdsWithIngredientFood.indexOf(userHousehold.value);
if (idx !== -1) editForm.data.householdsWithIngredientFood.splice(idx, 1);
}
await foodStore.actions.updateOne(editForm.data);
editForm.data = {} as IngredientFoodWithOnHand;
}
// ============================================================
// Bulk Actions
async function handleBulkAction(event: string, items: IngredientFoodWithOnHand[]) {
if (event === "delete-selected") {
const ids = items.map(item => item.id);
await foodStore.actions.deleteMany(ids);
}
else if (event === "assign-selected") {
bulkAssignEventHandler(items);
}
}
// ============================================================
// Alias Manager
const aliasManagerDialog = ref(false);
function updateFoodAlias(newAliases: IngredientFoodAlias[]) {
if (!editForm.data) {
return;
}
editForm.data.aliases = newAliases;
aliasManagerDialog.value = false;
}
// ============================================================
// Merge Foods
const mergeDialog = ref(false);
const fromFood = ref<IngredientFoodWithOnHand | null>(null);
const toFood = ref<IngredientFoodWithOnHand | null>(null);
const canMerge = computed(() => {
return fromFood.value && toFood.value && fromFood.value.id !== toFood.value.id;
});
async function mergeFoods() {
if (!canMerge.value || !fromFood.value || !toFood.value) {
return;
}
const { data } = await userApi.foods.merge(fromFood.value.id, toFood.value.id);
if (data) {
foodStore.actions.refresh();
}
}
// ============================================================
// Seed
const seedDialog = ref(false);
const locale = ref("");
const { locales: LOCALES, locale: currentLocale } = useLocales();
onMounted(() => {
locale.value = currentLocale.value;
});
const locales = LOCALES.filter(locale =>
(i18n.locales.value as LocaleObject[]).map(i18nLocale => i18nLocale.code).includes(locale.value as any),
);
async function seedDatabase() {
const { data } = await userApi.seeders.foods({ locale: locale.value });
if (data) {
foodStore.actions.refresh();
}
}
// ============================================================
// Bulk Assign Labels
const bulkAssignLabelDialog = ref(false);
const bulkAssignTarget = ref<IngredientFoodWithOnHand[]>([]);
const bulkAssignLabelId = ref<string | undefined>();
function bulkAssignEventHandler(selection: IngredientFoodWithOnHand[]) {
bulkAssignTarget.value = selection;
bulkAssignLabelDialog.value = true;
}
async function assignSelected() {
if (!bulkAssignLabelId.value) {
return;
}
for (const item of bulkAssignTarget.value) {
item.labelId = bulkAssignLabelId.value;
await foodStore.actions.updateOne(item);
}
bulkAssignTarget.value = [];
bulkAssignLabelId.value = undefined;
foodStore.actions.refresh();
}
</script>

View File

@@ -1,99 +1,5 @@
<template>
<div>
<!-- Create New Dialog -->
<BaseDialog
v-model="state.createDialog"
:title="$t('data-pages.labels.new-label')"
:icon="$globals.icons.tags"
can-submit
@submit="createLabel"
>
<v-card-text>
<MultiPurposeLabel :label="createLabelData" />
<div class="mt-4">
<v-text-field
v-model="createLabelData.name"
:label="$t('general.name')"
/>
<InputColor v-model="createLabelData.color!" />
</div>
</v-card-text>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
v-model="state.editDialog"
:icon="$globals.icons.tags"
:title="$t('data-pages.labels.edit-label')"
:submit-icon="$globals.icons.save"
:submit-text="$t('general.save')"
can-submit
@submit="editSaveLabel"
>
<v-card-text v-if="editLabel">
<MultiPurposeLabel :label="editLabel" />
<div class="mt-4">
<v-text-field
v-model="editLabel.name"
:label="$t('general.name')"
/>
<InputColor v-model="editLabel.color!" />
</div>
</v-card-text>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="state.deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteLabel"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<v-row>
<MultiPurposeLabel
v-if="deleteTarget"
class="mt-4 ml-4 mb-1"
:label="deleteTarget"
/>
</v-row>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="state.bulkDeleteDialog"
width="650px"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteSelected"
>
<v-card-text>
<p class="h4">
{{ $t('general.confirm-delete-generic-items') }}
</p>
<v-card variant="outlined">
<v-virtual-scroll
height="400"
item-height="25"
:items="bulkDeleteTarget"
>
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<!-- Seed Dialog -->
<BaseDialog
v-model="seedDialog"
@@ -127,7 +33,7 @@
</v-autocomplete>
<v-alert
v-if="labels && labels.length > 0"
v-if="labelStore.store.value && labelStore.store.value.length > 0"
type="error"
class="mb-0 text-body-2"
>
@@ -136,30 +42,20 @@
</v-card-text>
</BaseDialog>
<!-- Data Table -->
<BaseCardSectionTitle
<GroupDataPage
:icon="$globals.icons.tags"
section
:title="$t('data-pages.labels.labels')"
/>
<CrudTable
v-model:headers="tableHeaders"
:table-headers="tableHeaders"
:table-config="tableConfig"
:data="labels || []"
:data="labelStore.store.value || []"
:bulk-actions="[{ icon: $globals.icons.delete, text: $t('general.delete'), event: 'delete-selected' }]"
initial-sort="name"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
:create-form="createForm"
:edit-form="editForm"
@create-one="handleCreate"
@edit-one="handleEdit"
@delete-one="labelStore.actions.deleteOne"
@bulk-action="handleBulkAction"
>
<template #button-row>
<BaseButton
create
@click="state.createDialog = true"
>
{{ $t("general.create") }}
</BaseButton>
</template>
<template #[`item.name`]="{ item }">
<MultiPurposeLabel
v-if="item"
@@ -168,7 +64,12 @@
{{ item.name }}
</MultiPurposeLabel>
</template>
<template #button-bottom>
<template #create-dialog-top>
<MultiPurposeLabel :label="createForm.data" class="my-2" />
</template>
<template #table-button-bottom>
<BaseButton @click="seedDialog = true">
<template #icon>
{{ $globals.icons.database }}
@@ -176,172 +77,114 @@
{{ $t('data-pages.seed') }}
</BaseButton>
</template>
</CrudTable>
</GroupDataPage>
</div>
</template>
<script lang="ts">
import type { LocaleObject } from "@nuxtjs/i18n";
<script setup lang="ts">
import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
import { fieldTypes } from "~/composables/forms";
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import type { AutoFormItems } from "~/types/auto-forms";
import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
import { useLabelData, useLabelStore } from "~/composables/store";
import { useLabelStore } from "~/composables/store";
import type { TableHeaders, TableConfig } from "~/components/global/CrudTable.vue";
export default defineNuxtComponent({
components: { MultiPurposeLabel },
setup() {
const userApi = useUserApi();
const i18n = useI18n();
const userApi = useUserApi();
const i18n = useI18n();
const tableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
];
const state = reactive({
createDialog: false,
editDialog: false,
deleteDialog: false,
bulkDeleteDialog: false,
});
// ============================================================
// Labels
const labelData = useLabelData();
const labelStore = useLabelStore();
// Create
async function createLabel() {
await labelStore.actions.createOne(labelData.data);
labelData.reset();
state.createDialog = false;
}
// Delete
const deleteTarget = ref<MultiPurposeLabelSummary | null>(null);
function deleteEventHandler(item: MultiPurposeLabelSummary) {
state.deleteDialog = true;
deleteTarget.value = item;
}
async function deleteLabel() {
if (!deleteTarget.value) {
return;
}
await labelStore.actions.deleteOne(deleteTarget.value.id);
state.deleteDialog = false;
}
// Bulk Delete
const bulkDeleteTarget = ref<MultiPurposeLabelSummary[]>([]);
function bulkDeleteEventHandler(selection: MultiPurposeLabelSummary[]) {
bulkDeleteTarget.value = selection;
state.bulkDeleteDialog = true;
}
async function deleteSelected() {
const ids = bulkDeleteTarget.value.map(item => item.id);
await labelStore.actions.deleteMany(ids);
bulkDeleteTarget.value = [];
}
// Edit
const editLabel = ref<MultiPurposeLabelSummary | null>(null);
function editEventHandler(item: MultiPurposeLabelSummary) {
state.editDialog = true;
editLabel.value = item;
if (!editLabel.value.color) {
editLabel.value.color = "#959595";
}
}
async function editSaveLabel() {
if (!editLabel.value) {
return;
}
await labelStore.actions.updateOne(editLabel.value);
state.editDialog = false;
}
// ============================================================
// Seed
const seedDialog = ref(false);
const locale = ref("");
const { locales: LOCALES, locale: currentLocale } = useLocales();
onMounted(() => {
locale.value = currentLocale.value;
});
const locales = LOCALES.filter(locale =>
(i18n.locales.value as LocaleObject[]).map(i18nLocale => i18nLocale.code).includes(locale.value),
);
async function seedDatabase() {
const { data } = await userApi.seeders.labels({ locale: locale.value });
if (data) {
labelStore.actions.refresh();
}
}
return {
state,
tableConfig,
tableHeaders,
labels: labelStore.store,
validators,
normalizeFilter,
// create
createLabel,
createLabelData: labelData.data,
// edit
editLabel,
editEventHandler,
editSaveLabel,
// delete
deleteEventHandler,
deleteLabel,
deleteTarget,
bulkDeleteEventHandler,
deleteSelected,
bulkDeleteTarget,
// Seed
seedDatabase,
locales,
locale,
seedDialog,
};
const tableConfig: TableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders: TableHeaders[] = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
];
const labelStore = useLabelStore();
// ============================================================
// Form items (shared)
const formItems: AutoFormItems = [
{
label: i18n.t("general.name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
{
label: i18n.t("general.color"),
varName: "color",
type: fieldTypes.COLOR,
},
];
// ============================================================
// Create
const createForm = reactive({
items: formItems,
data: {
name: "",
color: "",
} as MultiPurposeLabelSummary,
});
async function handleCreate(createFormData: MultiPurposeLabelSummary) {
await labelStore.actions.createOne(createFormData);
createForm.data = { name: "", color: "#7417BE" } as MultiPurposeLabelSummary;
}
// ============================================================
// Edit
const editForm = reactive({
items: formItems,
data: {} as MultiPurposeLabelSummary,
});
async function handleEdit(editFormData: MultiPurposeLabelSummary) {
await labelStore.actions.updateOne(editFormData);
editForm.data = {} as MultiPurposeLabelSummary;
}
// ============================================================
// Bulk Actions
async function handleBulkAction(event: string, items: MultiPurposeLabelSummary[]) {
if (event === "delete-selected") {
const ids = items.filter(item => item.id != null).map(item => item.id!);
await labelStore.actions.deleteMany(ids);
}
}
// ============================================================
// Seed
const seedDialog = ref(false);
const locale = ref("");
const { locales: locales, locale: currentLocale } = useLocales();
onMounted(() => {
locale.value = currentLocale.value;
});
async function seedDatabase() {
const { data } = await userApi.seeders.labels({ locale: locale.value });
if (data) {
labelStore.actions.refresh();
}
}
</script>

View File

@@ -1,286 +1,121 @@
<template>
<div>
<!-- Create Dialog -->
<BaseDialog
v-model="state.createDialog"
:title="$t('data-pages.recipe-actions.new-recipe-action')"
:icon="$globals.icons.linkVariantPlus"
can-submit
@submit="createAction"
>
<v-card-text>
<v-form ref="domNewActionForm">
<v-text-field
v-model="createTarget.title"
autofocus
:label="$t('general.title')"
:rules="[validators.required]"
/>
<v-text-field
v-model="createTarget.url"
:label="$t('general.url')"
:rules="[validators.required]"
/>
<v-select
v-model="createTarget.actionType"
:items="actionTypeOptions"
:label="$t('data-pages.recipe-actions.action-type')"
:rules="[validators.required]"
/>
</v-form>
</v-card-text>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
v-model="state.editDialog"
:icon="$globals.icons.linkVariantPlus"
:title="$t('data-pages.recipe-actions.edit-recipe-action')"
:submit-text="$t('general.save')"
can-submit
@submit="editSaveAction"
>
<v-card-text v-if="editTarget">
<div class="mt-4">
<v-text-field
v-model="editTarget.title"
:label="$t('general.title')"
/>
</div>
<div class="mt-4">
<v-text-field
v-model="editTarget.url"
:label="$t('general.url')"
/>
</div>
<div class="mt-4">
<v-select
v-model="editTarget.actionType"
:items="actionTypeOptions"
:label="$t('data-pages.recipe-actions.action-type')"
/>
</div>
</v-card-text>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="state.deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteAction"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p
v-if="deleteTarget"
class="mt-4 ml-4"
>
{{ deleteTarget.title }}
</p>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="state.bulkDeleteDialog"
width="650px"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteSelected"
>
<v-card-text>
<p class="h4">
{{ $t('general.confirm-delete-generic-items') }}
</p>
<v-card variant="outlined">
<v-virtual-scroll
height="400"
item-height="25"
:items="bulkDeleteTarget"
>
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<!-- Data Table -->
<BaseCardSectionTitle
:icon="$globals.icons.linkVariantPlus"
section
:title="$t('data-pages.recipe-actions.recipe-actions-data')"
/>
<CrudTable
v-model:headers="tableHeaders"
<GroupDataPage
:icon="$globals.icons.categories"
:title="$t('data-pages.categories.category-data')"
:table-headers="tableHeaders"
:table-config="tableConfig"
:data="actions || []"
:data="actionStore.recipeActions.value || []"
:bulk-actions="[{ icon: $globals.icons.delete, text: $t('general.delete'), event: 'delete-selected' }]"
:create-form="createForm"
:edit-form="editForm"
initial-sort="title"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
>
<template #button-row>
<BaseButton
create
@click="state.createDialog = true"
>
{{ $t("general.create") }}
</BaseButton>
</template>
</CrudTable>
@create-one="handleCreate"
@edit-one="handleEdit"
@delete-one="actionStore.actions.deleteOne"
@bulk-action="handleBulkAction"
/>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { validators } from "~/composables/use-validators";
import { useGroupRecipeActions, useGroupRecipeActionData } from "~/composables/use-group-recipe-actions";
import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
import type { GroupRecipeActionOut } from "~/lib/api/types/household";
import type { TableHeaders, TableConfig } from "~/components/global/CrudTable.vue";
import type { AutoFormItems } from "~/types/auto-forms";
import { fieldTypes } from "~/composables/forms";
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const i18n = useI18n();
const tableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.title"),
value: "title",
show: true,
sortable: true,
},
{
text: i18n.t("general.url"),
value: "url",
show: true,
},
{
text: i18n.t("data-pages.recipe-actions.action-type"),
value: "actionType",
show: true,
sortable: true,
},
];
const state = reactive({
createDialog: false,
editDialog: false,
deleteDialog: false,
bulkDeleteDialog: false,
});
const actionData = useGroupRecipeActionData();
const actionStore = useGroupRecipeActions(null, null);
const actionTypeOptions = ["link", "post"];
// ============================================================
// Create Action
async function createAction() {
await actionStore.actions.createOne({
actionType: actionData.data.actionType,
title: actionData.data.title,
url: actionData.data.url,
} as GroupRecipeActionOut);
actionData.reset();
state.createDialog = false;
}
// ============================================================
// Edit Action
const editTarget = ref<GroupRecipeActionOut | null>(null);
function editEventHandler(item: GroupRecipeActionOut) {
state.editDialog = true;
editTarget.value = item;
}
async function editSaveAction() {
if (!editTarget.value) {
return;
}
await actionStore.actions.updateOne(editTarget.value);
state.editDialog = false;
}
// ============================================================
// Delete Action
const deleteTarget = ref<GroupRecipeActionOut | null>(null);
function deleteEventHandler(item: GroupRecipeActionOut) {
state.deleteDialog = true;
deleteTarget.value = item;
}
async function deleteAction() {
if (!deleteTarget.value || deleteTarget.value.id === undefined) {
return;
}
await actionStore.actions.deleteOne(deleteTarget.value.id);
state.deleteDialog = false;
}
// ============================================================
// Bulk Delete Action
const bulkDeleteTarget = ref<GroupRecipeActionOut[]>([]);
function bulkDeleteEventHandler(selection: GroupRecipeActionOut[]) {
bulkDeleteTarget.value = selection;
state.bulkDeleteDialog = true;
}
async function deleteSelected() {
const ids = bulkDeleteTarget.value.map(item => item.id);
await actionStore.actions.deleteMany(ids);
bulkDeleteTarget.value = [];
}
return {
state,
tableConfig,
tableHeaders,
actionTypeOptions,
actions: actionStore.recipeActions,
validators,
// create
createTarget: actionData.data,
createAction,
// edit
editTarget,
editEventHandler,
editSaveAction,
// delete
deleteTarget,
deleteEventHandler,
deleteAction,
// bulk delete
bulkDeleteTarget,
bulkDeleteEventHandler,
deleteSelected,
};
const tableConfig: TableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders: TableHeaders[] = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.title"),
value: "title",
show: true,
sortable: true,
},
{
text: i18n.t("general.url"),
value: "url",
show: true,
},
{
text: i18n.t("data-pages.recipe-actions.action-type"),
value: "actionType",
show: true,
sortable: true,
},
];
const actionStore = useGroupRecipeActions(null, null);
// ============================================================
// Form items (shared)
const formItems: AutoFormItems = [
{
label: i18n.t("general.title"),
varName: "title",
type: fieldTypes.TEXT,
rules: [validators.required],
},
{
label: i18n.t("general.url"),
varName: "url",
type: fieldTypes.TEXT,
rules: [validators.required, validators.url],
},
{
label: i18n.t("data-pages.recipe-actions.action-type"),
varName: "actionType",
type: fieldTypes.SELECT,
options: [{ text: "link" }, { text: "post" }],
rules: [validators.required],
},
];
// ============================================================
// Create
const createForm = reactive({
items: formItems,
data: {} as GroupRecipeActionOut,
});
async function handleCreate() {
await actionStore.actions.createOne(createForm.data);
createForm.data = {} as GroupRecipeActionOut;
}
// ============================================================
// Edit Action
const editForm = reactive({
items: formItems,
data: {} as GroupRecipeActionOut,
});
async function handleEdit(editFormData: GroupRecipeActionOut) {
await actionStore.actions.updateOne(editFormData);
editForm.data = {} as GroupRecipeActionOut;
}
// ============================================================
// Bulk Actions
async function handleBulkAction(event: string, items: GroupRecipeActionOut[]) {
console.log("Bulk Action Event:", event, "Items:", items);
if (event === "delete-selected") {
const ids = items.filter(item => item.id != null).map(item => item.id!);
await actionStore.actions.deleteMany(ids);
}
}
</script>

View File

@@ -1,248 +1,92 @@
<template>
<div>
<!-- Create Dialog -->
<BaseDialog
v-model="state.createDialog"
:title="$t('data-pages.tags.new-tag')"
<GroupDataPage
:icon="$globals.icons.tags"
can-submit
@submit="createTag"
>
<v-card-text>
<v-form ref="domNewTagForm">
<v-text-field
v-model="createTarget.name"
autofocus
:label="$t('general.name')"
:rules="[validators.required]"
/>
</v-form>
</v-card-text>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
v-model="state.editDialog"
:icon="$globals.icons.tags"
:title="$t('data-pages.tags.edit-tag')"
:submit-icon="$globals.icons.save"
:submit-text="$t('general.save')"
can-submit
@submit="editSaveTag"
>
<v-card-text v-if="editTarget">
<div class="mt-4">
<v-text-field
v-model="editTarget.name"
:label="$t('general.name')"
/>
</div>
</v-card-text>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="state.deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteTag"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p
v-if="deleteTarget"
class="mt-4 ml-4"
>
{{ deleteTarget.name }}
</p>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="state.bulkDeleteDialog"
width="650px"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteSelected"
>
<v-card-text>
<p class="h4">
{{ $t('general.confirm-delete-generic-items') }}
</p>
<v-card variant="outlined">
<v-virtual-scroll
height="400"
item-height="25"
:items="bulkDeleteTarget"
>
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<!-- Data Table -->
<BaseCardSectionTitle
:icon="$globals.icons.tags"
section
:title="$t('data-pages.tags.tag-data')"
/>
<CrudTable
v-model:headers="tableHeaders"
:table-headers="tableHeaders"
:table-config="tableConfig"
:data="tags || []"
:data="tagStore.store.value || []"
:bulk-actions="[{ icon: $globals.icons.delete, text: $t('general.delete'), event: 'delete-selected' }]"
initial-sort="name"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
>
<template #button-row>
<BaseButton
create
@click="state.createDialog = true"
>
{{ $t("general.create") }}
</BaseButton>
</template>
</CrudTable>
:create-form="createForm"
:edit-form="editForm"
@create-one="handleCreate"
@edit-one="handleEdit"
@delete-one="tagStore.actions.deleteOne"
@bulk-action="handleBulkAction"
/>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { validators } from "~/composables/use-validators";
import { useTagStore, useTagData } from "~/composables/store";
import type { RecipeTag } from "~/lib/api/types/admin";
import { useTagStore } from "~/composables/store";
import { fieldTypes } from "~/composables/forms";
import type { AutoFormItems } from "~/types/auto-forms";
import type { RecipeTag } from "~/lib/api/types/recipe";
import type { TableHeaders, TableConfig } from "~/components/global/CrudTable.vue";
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const i18n = useI18n();
const tableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
];
const state = reactive({
createDialog: false,
editDialog: false,
deleteDialog: false,
bulkDeleteDialog: false,
});
const tagData = useTagData();
const tagStore = useTagStore();
// ============================================================
// Create Tag
async function createTag() {
await tagStore.actions.createOne({
name: tagData.data.name,
slug: "",
});
tagData.reset();
state.createDialog = false;
}
// ============================================================
// Edit Tag
const editTarget = ref<RecipeTag | null>(null);
function editEventHandler(item: RecipeTag) {
state.editDialog = true;
editTarget.value = item;
}
async function editSaveTag() {
if (!editTarget.value) {
return;
}
await tagStore.actions.updateOne(editTarget.value);
state.editDialog = false;
}
// ============================================================
// Delete Tag
const deleteTarget = ref<RecipeTag | null>(null);
function deleteEventHandler(item: RecipeTag) {
state.deleteDialog = true;
deleteTarget.value = item;
}
async function deleteTag() {
if (!deleteTarget.value || deleteTarget.value.id === undefined) {
return;
}
await tagStore.actions.deleteOne(deleteTarget.value.id!);
state.deleteDialog = false;
}
// ============================================================
// Bulk Delete Tag
const bulkDeleteTarget = ref<RecipeTag[]>([]);
function bulkDeleteEventHandler(selection: RecipeTag[]) {
bulkDeleteTarget.value = selection;
state.bulkDeleteDialog = true;
}
async function deleteSelected() {
const ids = bulkDeleteTarget.value.map(item => item.id).filter(id => !!id);
await tagStore.actions.deleteMany(ids);
bulkDeleteTarget.value = [];
}
return {
state,
tableConfig,
tableHeaders,
tags: tagStore.store,
validators,
// create
createTarget: tagData.data,
createTag,
// edit
editTarget,
editEventHandler,
editSaveTag,
// delete
deleteTarget,
deleteEventHandler,
deleteTag,
// bulk delete
bulkDeleteTarget,
bulkDeleteEventHandler,
deleteSelected,
};
const tableConfig: TableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders: TableHeaders[] = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
];
const tagStore = useTagStore();
// ============================================================
// Form items (shared)
const formItems = [
{
label: i18n.t("general.name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
] as AutoFormItems;
// ============================================================
// Create
const createForm = reactive({
items: formItems,
data: { name: "" } as RecipeTag,
});
async function handleCreate(createFormData: RecipeTag) {
await tagStore.actions.createOne(createFormData);
createForm.data.name = "";
}
// ============================================================
// Edit
const editForm = reactive({
items: formItems,
data: {} as RecipeTag,
});
async function handleEdit(editFormData: RecipeTag) {
await tagStore.actions.updateOne(editFormData);
editForm.data = {} as RecipeTag;
}
// ============================================================
// Bulk Actions
async function handleBulkAction(event: string, items: RecipeTag[]) {
if (event === "delete-selected") {
const ids = items.filter(item => item.id != null).map(item => item.id!);
await tagStore.actions.deleteMany(ids);
}
}
</script>

View File

@@ -1,299 +1,133 @@
<template>
<div>
<!-- Create Dialog -->
<BaseDialog
v-model="state.createDialog"
:title="$t('data-pages.tools.new-tool')"
:icon="$globals.icons.potSteam"
can-submit
@submit="createTool"
>
<v-card-text>
<v-form ref="domNewToolForm">
<v-text-field
v-model="createTarget.name"
autofocus
:label="$t('general.name')"
:rules="[validators.required]"
/>
<v-checkbox
v-model="createTarget.onHand"
:label="$t('tool.on-hand')"
/>
</v-form>
</v-card-text>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
v-model="state.editDialog"
:icon="$globals.icons.potSteam"
:title="$t('data-pages.tools.edit-tool')"
:submit-text="$t('general.save')"
can-submit
@submit="editSaveTool"
>
<v-card-text v-if="editTarget">
<div class="mt-4">
<v-text-field
v-model="editTarget.name"
:label="$t('general.name')"
/>
<v-checkbox
v-model="editTarget.onHand"
:label="$t('tool.on-hand')"
hide-details
/>
</div>
</v-card-text>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="state.deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteTool"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p
v-if="deleteTarget"
class="mt-4 ml-4"
>
{{ deleteTarget.name }}
</p>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="state.bulkDeleteDialog"
width="650px"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteSelected"
>
<v-card-text>
<p class="h4">
{{ $t('general.confirm-delete-generic-items') }}
</p>
<v-card variant="outlined">
<v-virtual-scroll
height="400"
item-height="25"
:items="bulkDeleteTarget"
>
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<!-- Data Table -->
<BaseCardSectionTitle
:icon="$globals.icons.potSteam"
section
<GroupDataPage
:icon="$globals.icons.tools"
:title="$t('data-pages.tools.tool-data')"
/>
<CrudTable
v-model:headers="tableHeaders"
:table-headers="tableHeaders"
:table-config="tableConfig"
:data="tools || []"
:bulk-actions="[{ icon: $globals.icons.delete, text: $t('general.delete'), event: 'delete-selected' }]"
initial-sort="name"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
:create-form="createForm"
:edit-form="editForm"
@create-one="handleCreate"
@edit-one="handleEdit"
@delete-one="toolStore.actions.deleteOne"
@bulk-action="handleBulkAction"
>
<template #button-row>
<BaseButton
create
@click="state.createDialog = true"
>
{{ $t("general.create") }}
</BaseButton>
</template>
<template #[`item.onHand`]="{ item }">
<v-icon :color="item.onHand ? 'success' : undefined">
{{ item.onHand ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
</CrudTable>
</GroupDataPage>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { validators } from "~/composables/use-validators";
import { useToolStore, useToolData } from "~/composables/store";
import type { RecipeTool } from "~/lib/api/types/recipe";
import { fieldTypes } from "~/composables/forms";
import type { AutoFormItems } from "~/types/auto-forms";
import { useToolStore } from "~/composables/store";
import type { RecipeTool, RecipeToolCreate } from "~/lib/api/types/recipe";
import type { TableHeaders, TableConfig } from "~/components/global/CrudTable.vue";
interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const auth = useMealieAuth();
const tableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
{
text: i18n.t("tool.on-hand"),
value: "onHand",
show: true,
sortable: true,
},
];
const state = reactive({
createDialog: false,
editDialog: false,
deleteDialog: false,
bulkDeleteDialog: false,
});
const userHousehold = computed(() => auth.user.value?.householdSlug || "");
const toolData = useToolData();
const toolStore = useToolStore();
const tools = computed(() => toolStore.store.value.map((tools) => {
const onHand = tools.householdsWithTool?.includes(userHousehold.value) || false;
return { ...tools, onHand } as RecipeToolWithOnHand;
}));
// ============================================================
// Create Tool
async function createTool() {
if (toolData.data.onHand) {
toolData.data.householdsWithTool = [userHousehold.value];
}
else {
toolData.data.householdsWithTool = [];
}
await toolStore.actions.createOne({
name: toolData.data.name, householdsWithTool: toolData.data.householdsWithTool,
id: "",
slug: "",
});
toolData.reset();
state.createDialog = false;
}
// ============================================================
// Edit Tool
const editTarget = ref<RecipeToolWithOnHand | null>(null);
function editEventHandler(item: RecipeToolWithOnHand) {
state.editDialog = true;
editTarget.value = item;
}
async function editSaveTool() {
if (!editTarget.value) {
return;
}
if (editTarget.value.onHand && !editTarget.value.householdsWithTool?.includes(userHousehold.value)) {
if (!editTarget.value.householdsWithTool) {
editTarget.value.householdsWithTool = [userHousehold.value];
}
else {
editTarget.value.householdsWithTool.push(userHousehold.value);
}
}
else if (!editTarget.value.onHand && editTarget.value.householdsWithTool?.includes(userHousehold.value)) {
editTarget.value.householdsWithTool = editTarget.value.householdsWithTool.filter(
household => household !== userHousehold.value,
);
}
await toolStore.actions.updateOne(editTarget.value);
state.editDialog = false;
}
// ============================================================
// Delete Tool
const deleteTarget = ref<RecipeToolWithOnHand | null>(null);
function deleteEventHandler(item: RecipeToolWithOnHand) {
state.deleteDialog = true;
deleteTarget.value = item;
}
async function deleteTool() {
if (!deleteTarget.value || deleteTarget.value.id === undefined) {
return;
}
await toolStore.actions.deleteOne(deleteTarget.value.id);
state.deleteDialog = false;
}
// ============================================================
// Bulk Delete Tool
const bulkDeleteTarget = ref<RecipeToolWithOnHand[]>([]);
function bulkDeleteEventHandler(selection: RecipeToolWithOnHand[]) {
bulkDeleteTarget.value = selection;
state.bulkDeleteDialog = true;
}
async function deleteSelected() {
const ids = bulkDeleteTarget.value.map(item => item.id);
await toolStore.actions.deleteMany(ids);
bulkDeleteTarget.value = [];
}
return {
state,
tableConfig,
tableHeaders,
tools,
validators,
// create
createTarget: toolData.data,
createTool,
// edit
editTarget,
editEventHandler,
editSaveTool,
// delete
deleteTarget,
deleteEventHandler,
deleteTool,
// bulk delete
bulkDeleteTarget,
bulkDeleteEventHandler,
deleteSelected,
};
const i18n = useI18n();
const tableConfig: TableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders: TableHeaders[] = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
{
text: i18n.t("tool.on-hand"),
value: "onHand",
show: true,
sortable: true,
},
];
const auth = useMealieAuth();
const userHousehold = computed(() => auth.user.value?.householdSlug || "");
const toolStore = useToolStore();
const tools = computed(() => toolStore.store.value.map((tools) => {
const onHand = tools.householdsWithTool?.includes(userHousehold.value) || false;
return { ...tools, onHand } as RecipeToolWithOnHand;
}));
// ============================================================
// Form items (shared)
const formItems = [
{
label: i18n.t("general.name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
{
label: i18n.t("tool.on-hand"),
varName: "onHand",
type: fieldTypes.BOOLEAN,
},
] as AutoFormItems;
// ============================================================
// Create
const createForm = reactive({
items: formItems,
data: { name: "", onHand: false } as RecipeToolCreate,
});
async function handleCreate(createFormData: RecipeToolCreate) {
// @ts-expect-error createOne eroniusly expects id and slug which are not preset at time of creation
await toolStore.actions.createOne({ name: createFormData.name, householdsWithTool: createFormData.onHand ? [userHousehold.value] : [] } as RecipeToolCreate);
createForm.data = { name: "", onHand: false } as RecipeToolCreate;
}
// ============================================================
// Edit
const editForm = reactive({
items: formItems,
data: {} as RecipeToolWithOnHand,
});
async function handleEdit(editFormData: RecipeToolWithOnHand) {
// if list of households is undefined default to empty array
if (!editFormData.householdsWithTool) {
editFormData.householdsWithTool = [];
}
if (editFormData.onHand && !editFormData.householdsWithTool.includes(userHousehold.value)) {
editFormData.householdsWithTool.push(userHousehold.value);
}
else if (!editFormData.onHand && editFormData.householdsWithTool.includes(userHousehold.value)) {
const idx = editFormData.householdsWithTool.indexOf(userHousehold.value);
if (idx !== -1) editFormData.householdsWithTool.splice(idx, 1);
}
await toolStore.actions.updateOne({ ...editFormData, id: editFormData.id } as RecipeTool);
editForm.data = {} as RecipeToolWithOnHand;
}
// ============================================================
// Bulk Actions
async function handleBulkAction(event: string, items: RecipeToolWithOnHand[]) {
if (event === "delete-selected") {
const ids = items.filter(item => item.id != null).map(item => item.id!);
await toolStore.actions.deleteMany(ids);
}
}
</script>

View File

@@ -18,15 +18,16 @@
<v-autocomplete
v-model="fromUnit"
return-object
:items="store"
:items="unitStore"
:custom-filter="normalizeFilter"
item-title="name"
:label="$t('data-pages.units.source-unit')"
class="mt-2"
/>
<v-autocomplete
v-model="toUnit"
return-object
:items="store"
:items="unitStore"
:custom-filter="normalizeFilter"
item-title="name"
:label="$t('data-pages.units.target-unit')"
@@ -40,233 +41,16 @@
</v-card-text>
</BaseDialog>
<!-- Create Dialog -->
<BaseDialog
v-model="createDialog"
:icon="$globals.icons.units"
:title="$t('data-pages.units.create-unit')"
:submit-icon="$globals.icons.save"
:submit-text="$t('general.save')"
can-submit
@submit="createUnit"
>
<v-card-text>
<v-form ref="domNewUnitForm">
<v-text-field
v-model="createTarget.name"
autofocus
:label="$t('general.name')"
:hint="$t('data-pages.units.example-unit-singular')"
:rules="[validators.required]"
/>
<v-text-field
v-model="createTarget.pluralName"
:label="$t('general.plural-name')"
:hint="$t('data-pages.units.example-unit-plural')"
/>
<v-text-field
v-model="createTarget.abbreviation"
:label="$t('data-pages.units.abbreviation')"
:hint="$t('data-pages.units.example-unit-abbreviation-singular')"
/>
<v-text-field
v-model="createTarget.pluralAbbreviation"
:label="$t('data-pages.units.plural-abbreviation')"
:hint="$t('data-pages.units.example-unit-abbreviation-plural')"
/>
<v-text-field
v-model="createTarget.description"
:label="$t('data-pages.units.description')"
/>
<v-checkbox
v-model="createTarget.fraction"
hide-details
:label="$t('data-pages.units.display-as-fraction')"
/>
<v-checkbox
v-model="createTarget.useAbbreviation"
hide-details
:label="$t('data-pages.units.use-abbreviation')"
/>
<v-divider />
<v-card-text class="text-h6 mt-2 mb-3 pa-0">
{{ $t('data-pages.units.standardization') }}
</v-card-text>
<v-card-text class="ma-0 pa-0">
{{ $t('data-pages.units.unit-conversion') }}
</v-card-text>
<div class="d-flex flex-nowrap">
<v-number-input
v-model="createTarget.standardQuantity"
variant="underlined"
control-variant="hidden"
density="compact"
inset
:min="0"
:precision="null"
hide-details
class="mt-2"
style="max-width: 125px;"
/>
<v-autocomplete
v-model="createTarget.standardUnit"
:items="standardUnitItems"
clearable
hide-details
class="ml-2"
/>
</div>
</v-form>
</v-card-text>
</BaseDialog>
<!-- Alias Sub-Dialog -->
<RecipeDataAliasManagerDialog
v-if="editTarget"
v-if="editForm.data"
v-model="aliasManagerDialog"
:data="editTarget"
:data="editForm.data"
can-submit
@submit="updateUnitAlias"
@cancel="aliasManagerDialog = false"
/>
<!-- Edit Dialog -->
<BaseDialog
v-model="editDialog"
:icon="$globals.icons.units"
:title="$t('data-pages.units.edit-unit')"
:submit-icon="$globals.icons.save"
:submit-text="$t('general.save')"
can-submit
@submit="editSaveUnit"
>
<v-card-text v-if="editTarget">
<v-form ref="domEditUnitForm">
<v-text-field
v-model="editTarget.name"
:label="$t('general.name')"
:hint="$t('data-pages.units.example-unit-singular')"
:rules="[validators.required]"
/>
<v-text-field
v-model="editTarget.pluralName"
:label="$t('general.plural-name')"
:hint="$t('data-pages.units.example-unit-plural')"
/>
<v-text-field
v-model="editTarget.abbreviation"
:label="$t('data-pages.units.abbreviation')"
:hint="$t('data-pages.units.example-unit-abbreviation-singular')"
/>
<v-text-field
v-model="editTarget.pluralAbbreviation"
:label="$t('data-pages.units.plural-abbreviation')"
:hint="$t('data-pages.units.example-unit-abbreviation-plural')"
/>
<v-text-field
v-model="editTarget.description"
:label="$t('data-pages.units.description')"
/>
<v-checkbox
v-model="editTarget.fraction"
hide-details
:label="$t('data-pages.units.display-as-fraction')"
/>
<v-checkbox
v-model="editTarget.useAbbreviation"
hide-details
:label="$t('data-pages.units.use-abbreviation')"
/>
<v-divider />
<v-card-text class="text-h6 mt-2 mb-3 pa-0">
{{ $t('data-pages.units.standardization') }}
</v-card-text>
<v-card-text class="ma-0 pa-0">
{{ $t('data-pages.units.unit-conversion') }}
</v-card-text>
<div class="d-flex flex-nowrap">
<v-number-input
v-model="editTarget.standardQuantity"
variant="underlined"
control-variant="hidden"
density="compact"
inset
:min="0"
:precision="null"
hide-details
class="mt-2"
style="max-width: 125px;"
/>
<v-autocomplete
v-model="editTarget.standardUnit"
:items="standardUnitItems"
clearable
hide-details
class="ml-2"
/>
</div>
</v-form>
</v-card-text>
<template #custom-card-action>
<BaseButton
edit
@click="aliasManagerEventHandler"
>
{{ $t('data-pages.manage-aliases') }}
</BaseButton>
</template>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteUnit"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p
v-if="deleteTarget"
class="mt-4 ml-4"
>
{{ deleteTarget.name }}
</p>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="bulkDeleteDialog"
width="650px"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="deleteSelected"
>
<v-card-text>
<p class="h4">
{{ $t('general.confirm-delete-generic-items') }}
</p>
<v-card variant="outlined">
<v-virtual-scroll
height="400"
item-height="25"
:items="bulkDeleteTarget"
>
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<!-- Seed Dialog -->
<BaseDialog
v-model="seedDialog"
@@ -299,7 +83,7 @@
</v-autocomplete>
<v-alert
v-if="store && store.length > 0"
v-if="unitStore && unitStore.length > 0"
type="error"
class="mb-0 text-body-2"
>
@@ -308,63 +92,65 @@
</v-card-text>
</BaseDialog>
<!-- Data Table -->
<BaseCardSectionTitle
<GroupDataPage
:icon="$globals.icons.units"
section
:title="$t('data-pages.units.unit-data')"
/>
<CrudTable
v-model:headers="tableHeaders"
:title="$t('general.units')"
:table-headers="tableHeaders"
:table-config="tableConfig"
:data="store"
:data="unitStore || []"
:bulk-actions="[{ icon: $globals.icons.delete, text: $t('general.delete'), event: 'delete-selected' }]"
initial-sort="createdAt"
initial-sort-desc
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@create-one="createEventHandler"
@delete-selected="bulkDeleteEventHandler"
:create-form="createForm"
:edit-form="editForm"
@create-one="handleCreate"
@edit-one="handleEdit"
@delete-one="unitActions.deleteOne"
@bulk-action="handleBulkAction"
>
<template #button-row>
<template #table-button-row>
<BaseButton
create
@click="createDialog = true"
/>
<BaseButton @click="mergeDialog = true">
<template #icon>
{{ $globals.icons.externalLink }}
</template>
:icon="$globals.icons.externalLink"
@click="mergeDialog = true"
>
{{ $t('data-pages.combine') }}
</BaseButton>
</template>
<template #[`item.useAbbreviation`]="{ item }">
<v-icon :color="item.useAbbreviation ? 'success' : undefined">
{{ item.useAbbreviation ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
<template #[`item.fraction`]="{ item }">
<v-icon :color="item.fraction ? 'success' : undefined">
{{ item.fraction ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
<template #[`item.createdAt`]="{ item }">
{{ item.createdAt ? $d(new Date(item.createdAt)) : '' }}
</template>
<template #button-bottom>
<BaseButton @click="seedDialog = true">
<template #icon>
{{ $globals.icons.database }}
</template>
<template #table-button-bottom>
<BaseButton :icon="$globals.icons.database" @click="seedDialog = true">
{{ $t('data-pages.seed') }}
</BaseButton>
</template>
</CrudTable>
<template #edit-dialog-custom-action>
<BaseButton
:icon="$globals.icons.tags"
color="info"
@click="aliasManagerDialog = true"
>
{{ $t('data-pages.manage-aliases') }}
</BaseButton>
</template>
</GroupDataPage>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { LocaleObject } from "@nuxtjs/i18n";
import RecipeDataAliasManagerDialog from "~/components/Domain/Recipe/RecipeDataAliasManagerDialog.vue";
import { validators } from "~/composables/use-validators";
@@ -374,316 +160,213 @@ import type { StandardizedUnitType } from "~/lib/api/types/non-generated";
import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
import { useUnitStore } from "~/composables/store";
import type { VForm } from "~/types/auto-forms";
import type { AutoFormItems } from "~/types/auto-forms";
import type { TableHeaders, TableConfig } from "~/components/global/CrudTable.vue";
import { fieldTypes } from "~/composables/forms";
interface StandardUnitItem {
title: string;
value: StandardizedUnitType;
const userApi = useUserApi();
const i18n = useI18n();
const tableConfig: TableConfig = {
hideColumns: true,
canExport: true,
};
export default defineNuxtComponent({
components: { RecipeDataAliasManagerDialog },
setup() {
const userApi = useUserApi();
const i18n = useI18n();
const tableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
{
text: i18n.t("general.plural-name"),
value: "pluralName",
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.abbreviation"),
value: "abbreviation",
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.plural-abbreviation"),
value: "pluralAbbreviation",
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.use-abbv"),
value: "useAbbreviation",
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.description"),
value: "description",
show: false,
},
{
text: i18n.t("data-pages.units.fraction"),
value: "fraction",
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.standard-quantity"),
value: "standardQuantity",
show: false,
},
{
text: i18n.t("data-pages.units.standard-unit"),
value: "standardUnit",
show: false,
},
{
text: i18n.t("general.date-added"),
value: "createdAt",
show: false,
sortable: true,
},
];
const standardUnitItems = computed<StandardUnitItem[]>(() => [
{
title: i18n.t("data-pages.units.standard-unit-labels.fluid-ounce"),
value: "fluid_ounce",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.cup"),
value: "cup",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.ounce"),
value: "ounce",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.pound"),
value: "pound",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.milliliter"),
value: "milliliter",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.liter"),
value: "liter",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.gram"),
value: "gram",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.kilogram"),
value: "kilogram",
},
]);
const { store, actions: unitActions } = useUnitStore();
// ============================================================
// Create Units
const createDialog = ref(false);
const domNewUnitForm = ref<VForm>();
// we explicitly set booleans to false since forms don't POST unchecked boxes
const createTarget = ref<CreateIngredientUnit>({
name: "",
fraction: true,
useAbbreviation: false,
});
function createEventHandler() {
createDialog.value = true;
}
async function createUnit() {
if (!createTarget.value || !createTarget.value.name) {
return;
}
// @ts-expect-error the createOne function erroneously expects an id because it uses the IngredientUnit type
await unitActions.createOne(createTarget.value);
createDialog.value = false;
domNewUnitForm.value?.reset();
createTarget.value = {
name: "",
fraction: false,
useAbbreviation: false,
};
}
// ============================================================
// Edit Units
const editDialog = ref(false);
const editTarget = ref<IngredientUnit | null>(null);
function editEventHandler(item: IngredientUnit) {
editTarget.value = item;
editDialog.value = true;
}
async function editSaveUnit() {
if (!editTarget.value) {
return;
}
await unitActions.updateOne(editTarget.value);
editDialog.value = false;
}
// ============================================================
// Delete Units
const deleteDialog = ref(false);
const deleteTarget = ref<IngredientUnit | null>(null);
function deleteEventHandler(item: IngredientUnit) {
deleteTarget.value = item;
deleteDialog.value = true;
}
async function deleteUnit() {
if (!deleteTarget.value) {
return;
}
await unitActions.deleteOne(deleteTarget.value.id);
deleteDialog.value = false;
}
// ============================================================
// Bulk Delete Units
const bulkDeleteDialog = ref(false);
const bulkDeleteTarget = ref<IngredientUnit[]>([]);
function bulkDeleteEventHandler(selection: IngredientUnit[]) {
bulkDeleteTarget.value = selection;
bulkDeleteDialog.value = true;
}
async function deleteSelected() {
const ids = bulkDeleteTarget.value.map(item => item.id);
await unitActions.deleteMany(ids);
bulkDeleteTarget.value = [];
}
// ============================================================
// Alias Manager
const aliasManagerDialog = ref(false);
function aliasManagerEventHandler() {
aliasManagerDialog.value = true;
}
function updateUnitAlias(newAliases: IngredientUnitAlias[]) {
if (!editTarget.value) {
return;
}
editTarget.value.aliases = newAliases;
aliasManagerDialog.value = false;
}
// ============================================================
// Merge Units
const mergeDialog = ref(false);
const fromUnit = ref<IngredientUnit | null>(null);
const toUnit = ref<IngredientUnit | null>(null);
const canMerge = computed(() => {
return fromUnit.value && toUnit.value && fromUnit.value.id !== toUnit.value.id;
});
async function mergeUnits() {
if (!canMerge.value || !fromUnit.value || !toUnit.value) {
return;
}
const { data } = await userApi.units.merge(fromUnit.value.id, toUnit.value.id);
if (data) {
unitActions.refresh();
}
}
// ============================================================
// Seed
const seedDialog = ref(false);
const locale = ref("");
const { locales: LOCALES, locale: currentLocale } = useLocales();
onMounted(() => {
locale.value = currentLocale.value;
});
const locales = LOCALES.filter(locale =>
(i18n.locales.value as LocaleObject[]).map(i18nLocale => i18nLocale.code).includes(locale.value),
);
async function seedDatabase() {
const { data } = await userApi.seeders.units({ locale: locale.value });
if (data) {
unitActions.refresh();
}
}
return {
tableConfig,
tableHeaders,
standardUnitItems,
store,
validators,
normalizeFilter,
// Create
createDialog,
domNewUnitForm,
createEventHandler,
createUnit,
createTarget,
// Edit
editDialog,
editEventHandler,
editSaveUnit,
editTarget,
// Delete
deleteEventHandler,
deleteDialog,
deleteUnit,
deleteTarget,
// Bulk Delete
bulkDeleteDialog,
bulkDeleteEventHandler,
bulkDeleteTarget,
deleteSelected,
// Alias Manager
aliasManagerDialog,
aliasManagerEventHandler,
updateUnitAlias,
// Merge
canMerge,
mergeUnits,
mergeDialog,
fromUnit,
toUnit,
// Seed
seedDatabase,
locales,
locale,
seedDialog,
};
const tableHeaders: TableHeaders[] = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.name"),
value: "name",
show: true,
sortable: true,
},
{
text: i18n.t("general.plural-name"),
value: "pluralName",
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.abbreviation"),
value: "abbreviation",
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.plural-abbreviation"),
value: "pluralAbbreviation",
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.use-abbv"),
value: "useAbbreviation",
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.description"),
value: "description",
show: false,
},
{
text: i18n.t("data-pages.units.fraction"),
value: "fraction",
show: true,
sortable: true,
},
{
text: i18n.t("general.date-added"),
value: "createdAt",
show: false,
sortable: true,
},
];
const { store: unitStore, actions: unitActions } = useUnitStore();
// ============================================================
// Form items (shared)
const formItems: AutoFormItems = [
{
label: i18n.t("general.name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
{
label: i18n.t("general.plural-name"),
varName: "pluralName",
type: fieldTypes.TEXT,
},
{
label: i18n.t("data-pages.units.abbreviation"),
varName: "abbreviation",
type: fieldTypes.TEXT,
},
{
label: i18n.t("data-pages.units.plural-abbreviation"),
varName: "pluralAbbreviation",
type: fieldTypes.TEXT,
},
{
label: i18n.t("data-pages.units.description"),
varName: "description",
type: fieldTypes.TEXT,
},
{
label: i18n.t("data-pages.units.use-abbv"),
varName: "useAbbreviation",
type: fieldTypes.BOOLEAN,
},
{
label: i18n.t("data-pages.units.fraction"),
varName: "fraction",
type: fieldTypes.BOOLEAN,
},
];
// ============================================================
// Create
const createForm = reactive({
items: formItems,
data: {
name: "",
fraction: false,
useAbbreviation: false,
} as CreateIngredientUnit,
});
async function handleCreate(createFormData: CreateIngredientUnit) {
// @ts-expect-error createOne eroniusly expects id which is not preset at time of creation
await unitActions.createOne(createFormData);
createForm.data = {
name: "",
fraction: false,
useAbbreviation: false,
} as CreateIngredientUnit;
}
// ============================================================
// Edit
const editForm = reactive({
items: formItems,
data: {} as IngredientUnit,
});
async function handleEdit(editFormData: IngredientUnit) {
await unitActions.updateOne(editFormData);
editForm.data = {} as IngredientUnit;
}
// ============================================================
// Bulk Actions
async function handleBulkAction(event: string, items: IngredientUnit[]) {
if (event === "delete-selected") {
const ids = items.filter(item => item.id != null).map(item => item.id!);
await unitActions.deleteMany(ids);
}
}
// ============================================================
// Alias Manager
const aliasManagerDialog = ref(false);
function updateUnitAlias(newAliases: IngredientUnitAlias[]) {
if (!editForm.data) {
return;
}
editForm.data.aliases = newAliases;
aliasManagerDialog.value = false;
}
// ============================================================
// Merge Units
const mergeDialog = ref(false);
const fromUnit = ref<IngredientUnit | null>(null);
const toUnit = ref<IngredientUnit | null>(null);
const canMerge = computed(() => {
return fromUnit.value && toUnit.value && fromUnit.value.id !== toUnit.value.id;
});
async function mergeUnits() {
if (!canMerge.value || !fromUnit.value || !toUnit.value) {
return;
}
const { data } = await userApi.units.merge(fromUnit.value.id, toUnit.value.id);
if (data) {
unitActions.refresh();
}
}
// ============================================================
// Seed
const seedDialog = ref(false);
const locale = ref("");
const { locales: LOCALES, locale: currentLocale } = useLocales();
onMounted(() => {
locale.value = currentLocale.value;
});
const locales = LOCALES.filter(locale =>
(i18n.locales.value as LocaleObject[]).map(i18nLocale => i18nLocale.code).includes(locale.value as any),
);
async function seedDatabase() {
const { data } = await userApi.seeders.units({ locale: locale.value });
if (data) {
unitActions.refresh();
}
}
</script>

View File

@@ -2,6 +2,8 @@ import type { VForm as VuetifyForm } from "vuetify/components/VForm";
type FormFieldType = "text" | "textarea" | "list" | "select" | "object" | "boolean" | "color" | "password";
export type FormValidationRule = (value: any) => boolean | string;
export interface FormSelectOption {
text: string;
}
@@ -13,10 +15,11 @@ export interface FormField {
hint?: string;
varName: string;
type: FormFieldType;
rules?: string[];
rules?: FormValidationRule[];
disableUpdate?: boolean;
disableCreate?: boolean;
options?: FormSelectOption[];
selectReturnValue?: string;
}
export type AutoFormItems = FormField[];