mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
* feat: Add item templates feature (#435) Add ability to create and manage item templates for quick item creation. Templates store default values and custom fields that can be applied when creating new items. Backend changes: - New ItemTemplate and TemplateField Ent schemas - Template CRUD API endpoints - Create item from template endpoint Frontend changes: - Templates management page with create/edit/delete - Template selector in item creation modal - 'Use as Template' action on item detail page - Templates link in navigation menu * refactor: Improve template item creation with a single query - Add `CreateFromTemplate` method to ItemsRepository that creates items with all template data (including custom fields) in a single atomic transaction, replacing the previous two-phase create-then-update pattern - Fix `GetOne` to require group ID parameter so templates can only be accessed by users in the owning group (security fix) - Simplify `HandleItemTemplatesCreateItem` handler using the new transactional method * Refactor item template types and formatting Updated type annotations in CreateModal.vue to use specific ItemTemplate types instead of 'any'. Improved code formatting for template fields and manufacturer display. Also refactored warranty field logic in item details page for better readability. This resolves the linter issues as well that the bot in github keeps nagging at. * Add 'id' property to template fields Introduces an 'id' property to each field object in CreateModal.vue and item details page to support unique identification of fields. This change prepares the codebase for future enhancements that may require field-level identification. * Removed redundant SQL migrations. Removed redundant SQL migrations per @tankerkiller125's findings. * Updates to PR #1099. Regarding pull #1099. Fixed an issue causing some conflict with GUIDs and old rows in the migration files. * Add new fields and location edge to ItemTemplate Addresses recommendations from @tonyaellie. * Relocated add template button * Added more default fields to the template * Added translation of all strings (think so?) * Make oval buttons round * Added duplicate button to the template (this required a rewrite of the migration files, I made sure only 1 exists per DB type) * Added a Save as template button to a item detail view (this creates a template with all the current data of that item) * Changed all occurrences of space to gap and flex where applicable. * Made template selection persistent after item created. * Collapsible template info on creation view. * Updates to translation and fix for labels/locations I also added a test in here because I keep missing small function tests. That should prevent that from happening again. * Linted * Bring up to date with main, fix some lint/type check issues * In theory fix playwright tests * Fix defaults being unable to be nullable/empty (and thus limiting flexibility) * Last few fixes I think * Forgot to fix the golang tests --------- Co-authored-by: Matthew Kilgore <matthew@kilgore.dev>
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
<template>
|
||||
<BaseModal :dialog-id="DialogID.CreateItem" :title="$t('components.item.create_modal.title')">
|
||||
<template #header-actions>
|
||||
<div class="flex">
|
||||
<div class="flex gap-2">
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<!-- Template selector button -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TemplateSelector v-model="selectedTemplate" compact @template-selected="handleTemplateSelected" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ $t("components.template.apply_template") }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ButtonGroup>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -31,6 +41,92 @@
|
||||
|
||||
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||
<LocationSelector v-model="form.location" />
|
||||
|
||||
<!-- Template Info Display - Collapsible banner with distinct styling -->
|
||||
<div v-if="templateData" class="rounded-lg border-l-4 border-l-primary bg-primary/5 p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex flex-1 items-start gap-2">
|
||||
<MdiFileDocumentOutline class="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-foreground">
|
||||
{{ $t("components.template.using_template", { name: templateData.name }) }}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
@click="showTemplateDetails = !showTemplateDetails"
|
||||
>
|
||||
<span v-if="!showTemplateDetails">{{ $t("components.template.show_defaults") }}</span>
|
||||
<span v-else>{{ $t("components.template.hide_defaults") }}</span>
|
||||
<MdiChevronDown class="size-4 transition-transform" :class="{ 'rotate-180': showTemplateDetails }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0"
|
||||
:aria-label="$t('components.item.create_modal.clear_template')"
|
||||
@click="clearTemplate"
|
||||
>
|
||||
<MdiClose class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible details section -->
|
||||
<div v-if="showTemplateDetails" class="mt-3 border-t border-primary/20 pt-3">
|
||||
<div class="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||
<p v-if="templateData.description" class="text-foreground/80">{{ templateData.description }}</p>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<div v-if="templateData.defaultName">
|
||||
<span class="font-medium">{{ $t("global.name") }}:</span> {{ templateData.defaultName }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">{{ $t("global.quantity") }}:</span> {{ templateData.defaultQuantity }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">{{ $t("global.insured") }}:</span>
|
||||
{{ templateData.defaultInsured ? $t("global.yes") : $t("global.no") }}
|
||||
</div>
|
||||
<div v-if="templateData.defaultManufacturer">
|
||||
<span class="font-medium">{{ $t("components.template.form.manufacturer") }}:</span>
|
||||
{{ templateData.defaultManufacturer }}
|
||||
</div>
|
||||
<div v-if="templateData.defaultModelNumber">
|
||||
<span class="font-medium">{{ $t("components.template.form.model_number") }}:</span>
|
||||
{{ templateData.defaultModelNumber }}
|
||||
</div>
|
||||
<div v-if="templateData.defaultLifetimeWarranty">
|
||||
<span class="font-medium">{{ $t("components.template.form.lifetime_warranty") }}:</span>
|
||||
{{ $t("global.yes") }}
|
||||
</div>
|
||||
<div v-if="templateData.defaultLocation">
|
||||
<span class="font-medium">{{ $t("components.template.form.location") }}:</span>
|
||||
{{ templateData.defaultLocation.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="templateData.defaultLabels && templateData.defaultLabels.length > 0" class="mt-1">
|
||||
<span class="font-medium">{{ $t("global.labels") }}:</span>
|
||||
{{ templateData.defaultLabels.map((l: any) => l.name).join(", ") }}
|
||||
</div>
|
||||
<div v-if="templateData.defaultDescription" class="mt-1">
|
||||
<p class="font-medium">{{ $t("components.template.form.item_description") }}:</p>
|
||||
<p class="ml-2">{{ templateData.defaultDescription }}</p>
|
||||
</div>
|
||||
<div v-if="templateData.fields && templateData.fields.length > 0" class="mt-1">
|
||||
<p class="font-medium">{{ $t("components.template.form.custom_fields") }}:</p>
|
||||
<ul class="ml-4 flex list-none flex-col gap-1">
|
||||
<li v-for="field in templateData.fields" :key="field.id">
|
||||
<span class="font-medium">{{ field.name }}:</span>
|
||||
<span> {{ field.textValue || $t("components.template.empty_value") }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ItemSelector
|
||||
v-if="subItemCreate"
|
||||
v-model="parent"
|
||||
@@ -177,7 +273,7 @@
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ItemCreate, LocationOut } from "~~/lib/api/types/data-contracts";
|
||||
import type { ItemCreate, ItemTemplateOut, ItemTemplateSummary, LocationOut } from "~~/lib/api/types/data-contracts";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
import MdiBarcode from "~icons/mdi/barcode";
|
||||
@@ -188,10 +284,14 @@
|
||||
import MdiRotateClockwise from "~icons/mdi/rotate-clockwise";
|
||||
import MdiStarOutline from "~icons/mdi/star-outline";
|
||||
import MdiStar from "~icons/mdi/star";
|
||||
import MdiFileDocumentOutline from "~icons/mdi/file-document-outline";
|
||||
import MdiChevronDown from "~icons/mdi/chevron-down";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
import { AttachmentTypes } from "~~/lib/api/types/non-generated";
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
import LabelSelector from "~/components/Label/Selector.vue";
|
||||
import ItemSelector from "~/components/Item/Selector.vue";
|
||||
import TemplateSelector from "~/components/Template/Selector.vue";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
@@ -248,8 +348,13 @@
|
||||
|
||||
const nameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const LAST_TEMPLATE_KEY = "homebox:lastUsedTemplate";
|
||||
|
||||
const loading = ref(false);
|
||||
const focused = ref(false);
|
||||
const selectedTemplate = ref<ItemTemplateSummary | null>(null);
|
||||
const templateData = ref<ItemTemplateOut | null>(null);
|
||||
const showTemplateDetails = ref(false);
|
||||
const form = reactive({
|
||||
location: locations.value && locations.value.length > 0 ? locations.value[0] : ({} as LocationOut),
|
||||
parentId: null,
|
||||
@@ -261,6 +366,94 @@
|
||||
photos: [] as PhotoPreview[],
|
||||
});
|
||||
|
||||
async function handleTemplateSelected(template: ItemTemplateSummary | null) {
|
||||
if (!template) {
|
||||
// Template was deselected, clear template data and remove from storage
|
||||
templateData.value = null;
|
||||
form.quantity = 1;
|
||||
localStorage.removeItem(LAST_TEMPLATE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load full template details
|
||||
const { data, error } = await api.templates.get(template.id);
|
||||
if (error || !data) {
|
||||
toast.error(t("components.template.toast.load_failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Store template data for display and item creation
|
||||
templateData.value = data;
|
||||
|
||||
// Pre-fill form with template defaults
|
||||
form.quantity = data.defaultQuantity;
|
||||
if (data.defaultName) {
|
||||
form.name = data.defaultName;
|
||||
}
|
||||
if (data.defaultDescription) {
|
||||
form.description = data.defaultDescription;
|
||||
}
|
||||
// Pre-fill location if template has one and current form doesn't
|
||||
if (data.defaultLocation && !form.location?.id) {
|
||||
const found = locations.value.find(l => l.id === data.defaultLocation!.id);
|
||||
if (found) {
|
||||
form.location = found;
|
||||
}
|
||||
}
|
||||
// Pre-fill labels from template
|
||||
if (data.defaultLabels && data.defaultLabels.length > 0) {
|
||||
form.labels = data.defaultLabels.map(l => l.id);
|
||||
}
|
||||
|
||||
// Save template ID to localStorage for persistence
|
||||
localStorage.setItem(LAST_TEMPLATE_KEY, template.id);
|
||||
|
||||
toast.success(t("components.template.toast.applied", { name: data.name }));
|
||||
}
|
||||
|
||||
async function restoreLastTemplate() {
|
||||
const lastTemplateId = localStorage.getItem(LAST_TEMPLATE_KEY);
|
||||
if (!lastTemplateId) return;
|
||||
|
||||
// Load the template details
|
||||
const { data, error } = await api.templates.get(lastTemplateId);
|
||||
if (error || !data) {
|
||||
// Template might have been deleted, clear the stored ID
|
||||
localStorage.removeItem(LAST_TEMPLATE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the template
|
||||
selectedTemplate.value = { id: data.id, name: data.name, description: data.description } as ItemTemplateSummary;
|
||||
templateData.value = data;
|
||||
form.quantity = data.defaultQuantity;
|
||||
if (data.defaultName) {
|
||||
form.name = data.defaultName;
|
||||
}
|
||||
if (data.defaultDescription) {
|
||||
form.description = data.defaultDescription;
|
||||
}
|
||||
// Pre-fill location if template has one
|
||||
if (data.defaultLocation) {
|
||||
const found = locations.value.find(l => l.id === data.defaultLocation!.id);
|
||||
if (found) {
|
||||
form.location = found;
|
||||
}
|
||||
}
|
||||
// Pre-fill labels from template
|
||||
if (data.defaultLabels && data.defaultLabels.length > 0) {
|
||||
form.labels = data.defaultLabels.map(l => l.id);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTemplate() {
|
||||
selectedTemplate.value = null;
|
||||
templateData.value = null;
|
||||
showTemplateDetails.value = false;
|
||||
form.quantity = 1;
|
||||
localStorage.removeItem(LAST_TEMPLATE_KEY);
|
||||
}
|
||||
|
||||
watch(
|
||||
parent,
|
||||
newParent => {
|
||||
@@ -364,6 +557,9 @@
|
||||
if (labelId.value) {
|
||||
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
|
||||
}
|
||||
|
||||
// Restore last used template if available
|
||||
await restoreLastTemplate();
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
@@ -384,16 +580,36 @@
|
||||
|
||||
if (shift?.value) close = false;
|
||||
|
||||
const out: ItemCreate = {
|
||||
parentId: form.parentId,
|
||||
name: form.name,
|
||||
quantity: form.quantity,
|
||||
description: form.description,
|
||||
locationId: form.location.id as string,
|
||||
labelIds: form.labels,
|
||||
};
|
||||
let error, data;
|
||||
|
||||
const { error, data } = await api.items.create(out);
|
||||
// If a template is selected, use the template creation endpoint
|
||||
if (templateData.value) {
|
||||
const templateRequest = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
locationId: form.location.id as string,
|
||||
labelIds: form.labels,
|
||||
quantity: form.quantity,
|
||||
};
|
||||
|
||||
const result = await api.templates.createItem(templateData.value.id, templateRequest);
|
||||
error = result.error;
|
||||
data = result.data;
|
||||
} else {
|
||||
// Normal item creation without template
|
||||
const out: ItemCreate = {
|
||||
parentId: form.parentId,
|
||||
name: form.name,
|
||||
quantity: form.quantity,
|
||||
description: form.description,
|
||||
locationId: form.location.id as string,
|
||||
labelIds: form.labels,
|
||||
};
|
||||
|
||||
const result = await api.items.create(out);
|
||||
error = result.error;
|
||||
data = result.data;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
loading.value = false;
|
||||
@@ -434,6 +650,9 @@
|
||||
form.color = "";
|
||||
form.photos = [];
|
||||
form.labels = [];
|
||||
selectedTemplate.value = null;
|
||||
templateData.value = null;
|
||||
showTemplateDetails.value = false;
|
||||
focused.value = false;
|
||||
loading.value = false;
|
||||
|
||||
|
||||
119
frontend/components/Template/Card.vue
Normal file
119
frontend/components/Template/Card.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import MdiPencil from "~icons/mdi/pencil";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import MdiContentCopy from "~icons/mdi/content-copy";
|
||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ItemTemplateSummary, ItemTemplateCreate } from "~/lib/api/types/data-contracts";
|
||||
|
||||
const props = defineProps<{
|
||||
template: ItemTemplateSummary;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
deleted: [];
|
||||
duplicated: [id: string];
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
const confirm = useConfirm();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function handleDelete() {
|
||||
const { isCanceled } = await confirm.open(t("components.template.confirm_delete"));
|
||||
if (isCanceled) return;
|
||||
|
||||
const { error } = await api.templates.delete(props.template.id);
|
||||
if (error) {
|
||||
toast.error(t("components.template.toast.delete_failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("components.template.toast.deleted"));
|
||||
emit("deleted");
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
// First, get the full template details
|
||||
const { data: fullTemplate, error: getError } = await api.templates.get(props.template.id);
|
||||
if (getError || !fullTemplate) {
|
||||
toast.error(t("components.template.toast.load_failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
// Create a duplicate with "(Copy)" suffix
|
||||
const duplicateData: ItemTemplateCreate = {
|
||||
name: `${fullTemplate.name} (Copy)`,
|
||||
description: fullTemplate.description,
|
||||
notes: fullTemplate.notes,
|
||||
defaultName: fullTemplate.defaultName,
|
||||
defaultDescription: fullTemplate.defaultDescription,
|
||||
defaultQuantity: fullTemplate.defaultQuantity,
|
||||
defaultInsured: fullTemplate.defaultInsured,
|
||||
defaultManufacturer: fullTemplate.defaultManufacturer,
|
||||
defaultModelNumber: fullTemplate.defaultModelNumber,
|
||||
defaultLifetimeWarranty: fullTemplate.defaultLifetimeWarranty,
|
||||
defaultWarrantyDetails: fullTemplate.defaultWarrantyDetails,
|
||||
defaultLocationId: fullTemplate.defaultLocation?.id ?? "",
|
||||
defaultLabelIds: fullTemplate.defaultLabels?.map(l => l.id) || [],
|
||||
includeWarrantyFields: fullTemplate.includeWarrantyFields,
|
||||
includePurchaseFields: fullTemplate.includePurchaseFields,
|
||||
includeSoldFields: fullTemplate.includeSoldFields,
|
||||
fields: fullTemplate.fields.map(field => ({
|
||||
id: NIL_UUID,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
textValue: field.textValue,
|
||||
})),
|
||||
};
|
||||
|
||||
const { data, error } = await api.templates.create(duplicateData);
|
||||
if (error) {
|
||||
toast.error(t("components.template.toast.duplicate_failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("components.template.toast.duplicated", { name: duplicateData.name }));
|
||||
emit("duplicated", data.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="truncate">{{ template.name }}</CardTitle>
|
||||
<CardDescription v-if="template.description" class="line-clamp-2">
|
||||
{{ template.description }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter class="flex justify-end gap-1">
|
||||
<Button size="icon" variant="outline" class="size-8" as-child :title="$t('components.template.card.edit')">
|
||||
<NuxtLink :to="`/template/${template.id}`">
|
||||
<MdiPencil class="size-4" />
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
class="size-8"
|
||||
:title="$t('components.template.card.duplicate')"
|
||||
@click="handleDuplicate"
|
||||
>
|
||||
<MdiContentCopy class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
class="size-8"
|
||||
:title="$t('components.template.card.delete')"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<MdiDelete class="size-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
211
frontend/components/Template/CreateModal.vue
Normal file
211
frontend/components/Template/CreateModal.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<BaseModal :dialog-id="DialogID.CreateTemplate" :title="$t('components.template.create_modal.title')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||
<FormTextField
|
||||
v-model="form.name"
|
||||
:autofocus="true"
|
||||
:label="$t('components.template.form.template_name')"
|
||||
:max-length="255"
|
||||
:min-length="1"
|
||||
/>
|
||||
<FormTextArea
|
||||
v-model="form.description"
|
||||
:label="$t('components.template.form.template_description')"
|
||||
:max-length="1000"
|
||||
/>
|
||||
|
||||
<Separator class="my-2" />
|
||||
<h3 class="text-sm font-medium">{{ $t("components.template.form.default_item_values") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<FormTextField v-model="form.defaultName" :label="$t('components.template.form.item_name')" :max-length="255" />
|
||||
<FormTextArea
|
||||
v-model="form.defaultDescription"
|
||||
:label="$t('components.template.form.item_description')"
|
||||
:max-length="1000"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormTextField v-model.number="form.defaultQuantity" :label="$t('global.quantity')" type="number" :min="1" />
|
||||
<FormTextField
|
||||
v-model="form.defaultModelNumber"
|
||||
:label="$t('components.template.form.model_number')"
|
||||
:max-length="255"
|
||||
/>
|
||||
</div>
|
||||
<FormTextField
|
||||
v-model="form.defaultManufacturer"
|
||||
:label="$t('components.template.form.manufacturer')"
|
||||
:max-length="255"
|
||||
/>
|
||||
<LocationSelector
|
||||
v-model="form.defaultLocationObject"
|
||||
:label="$t('components.template.form.default_location')"
|
||||
/>
|
||||
<LabelSelector v-model="form.defaultLabelIds" :labels="labels ?? []" />
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="defaultInsured" v-model:checked="form.defaultInsured" />
|
||||
<Label for="defaultInsured" class="text-sm">{{ $t("global.insured") }}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="defaultLifetimeWarranty" v-model:checked="form.defaultLifetimeWarranty" />
|
||||
<Label for="defaultLifetimeWarranty" class="text-sm">{{
|
||||
$t("components.template.form.lifetime_warranty")
|
||||
}}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="my-2" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">{{ $t("components.template.form.custom_fields") }}</h3>
|
||||
<Button type="button" size="sm" variant="outline" @click="addField">
|
||||
<MdiPlus class="mr-1 size-4" />
|
||||
{{ $t("global.add") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="form.fields.length > 0" class="flex flex-col gap-2">
|
||||
<div v-for="(field, idx) in form.fields" :key="idx" class="flex items-end gap-2">
|
||||
<FormTextField
|
||||
v-model="field.name"
|
||||
:label="$t('components.template.form.field_name')"
|
||||
:max-length="255"
|
||||
class="flex-1"
|
||||
/>
|
||||
<FormTextField
|
||||
v-model="field.textValue"
|
||||
:label="$t('components.template.form.default_value')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button type="button" size="icon" variant="ghost" @click="form.fields.splice(idx, 1)">
|
||||
<MdiDelete class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted-foreground">{{ $t("components.template.form.no_custom_fields") }}</p>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button type="submit" :loading="loading">{{ $t("global.create") }}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import MdiPlus from "~icons/mdi/plus";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import { useDialog } from "~/components/ui/dialog-provider";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import LabelSelector from "~/components/Label/Selector.vue";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import type { LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
const emit = defineEmits<{ created: [] }>();
|
||||
const { closeDialog } = useDialog();
|
||||
|
||||
const labelStore = useLabelStore();
|
||||
const labels = computed(() => labelStore.labels);
|
||||
|
||||
const loading = ref(false);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
description: "",
|
||||
notes: "",
|
||||
defaultName: "",
|
||||
defaultDescription: "",
|
||||
defaultQuantity: 1,
|
||||
defaultInsured: false,
|
||||
defaultManufacturer: "",
|
||||
defaultModelNumber: "",
|
||||
defaultLifetimeWarranty: false,
|
||||
defaultWarrantyDetails: "",
|
||||
defaultLocationId: null as string | null,
|
||||
defaultLocationObject: null as LocationSummary | null,
|
||||
defaultLabelIds: [] as string[],
|
||||
includeWarrantyFields: false,
|
||||
includePurchaseFields: false,
|
||||
includeSoldFields: false,
|
||||
fields: [] as Array<{ id: string; name: string; type: "text"; textValue: string }>,
|
||||
});
|
||||
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
function addField() {
|
||||
form.fields.push({ id: NIL_UUID, name: "", type: "text", textValue: "" });
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(form, {
|
||||
name: "",
|
||||
description: "",
|
||||
notes: "",
|
||||
defaultName: "",
|
||||
defaultDescription: "",
|
||||
defaultQuantity: 1,
|
||||
defaultInsured: false,
|
||||
defaultManufacturer: "",
|
||||
defaultModelNumber: "",
|
||||
defaultLifetimeWarranty: false,
|
||||
defaultWarrantyDetails: "",
|
||||
defaultLocationId: null,
|
||||
defaultLocationObject: null,
|
||||
defaultLabelIds: [],
|
||||
includeWarrantyFields: false,
|
||||
includePurchaseFields: false,
|
||||
includeSoldFields: false,
|
||||
fields: [],
|
||||
});
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
async function create() {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
|
||||
// Prepare the data with proper format for API
|
||||
const createData = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
notes: form.notes,
|
||||
defaultName: form.defaultName || null,
|
||||
defaultDescription: form.defaultDescription || null,
|
||||
defaultQuantity: form.defaultQuantity,
|
||||
defaultInsured: form.defaultInsured,
|
||||
defaultManufacturer: form.defaultManufacturer || null,
|
||||
defaultModelNumber: form.defaultModelNumber || null,
|
||||
defaultLifetimeWarranty: form.defaultLifetimeWarranty,
|
||||
defaultWarrantyDetails: form.defaultWarrantyDetails || null,
|
||||
defaultLocationId: form.defaultLocationObject?.id ?? null,
|
||||
defaultLabelIds: form.defaultLabelIds,
|
||||
includeWarrantyFields: form.includeWarrantyFields,
|
||||
includePurchaseFields: form.includePurchaseFields,
|
||||
includeSoldFields: form.includeSoldFields,
|
||||
fields: form.fields,
|
||||
};
|
||||
|
||||
const { error } = await api.templates.create(createData);
|
||||
if (error) {
|
||||
toast.error(t("components.template.toast.create_failed"));
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("components.template.toast.created"));
|
||||
reset();
|
||||
closeDialog(DialogID.CreateTemplate);
|
||||
emit("created");
|
||||
}
|
||||
</script>
|
||||
149
frontend/components/Template/Selector.vue
Normal file
149
frontend/components/Template/Selector.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<!-- Compact mode: icon button only -->
|
||||
<Popover v-if="compact" v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
:id="id"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:class="value ? 'border-primary text-primary' : ''"
|
||||
>
|
||||
<MdiFileDocumentOutline class="size-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-72 p-0" align="end">
|
||||
<Command :ignore-filter="true">
|
||||
<CommandInput
|
||||
v-model="search"
|
||||
:placeholder="$t('components.template.selector.search')"
|
||||
:display-value="_ => ''"
|
||||
/>
|
||||
<CommandEmpty>{{ $t("components.template.selector.not_found") }}</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
:value="template.id"
|
||||
@select="selectTemplate(template)"
|
||||
>
|
||||
<Check :class="cn('mr-2 h-4 w-4', value?.id === template.id ? 'opacity-100' : 'opacity-0')" />
|
||||
<div class="flex w-full flex-col">
|
||||
<div>{{ template.name }}</div>
|
||||
<div v-if="template.description" class="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
||||
{{ template.description }}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Full mode: label + full-width button -->
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<Label :for="id" class="px-1">{{ $t("components.template.selector.label") }}</Label>
|
||||
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
{{ value && value.name ? value.name : $t("components.template.selector.select") }}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[--reka-popper-anchor-width] p-0">
|
||||
<Command :ignore-filter="true">
|
||||
<CommandInput
|
||||
v-model="search"
|
||||
:placeholder="$t('components.template.selector.search')"
|
||||
:display-value="_ => ''"
|
||||
/>
|
||||
<CommandEmpty>{{ $t("components.template.selector.not_found") }}</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
:value="template.id"
|
||||
@select="selectTemplate(template)"
|
||||
>
|
||||
<Check :class="cn('mr-2 h-4 w-4', value?.id === template.id ? 'opacity-100' : 'opacity-0')" />
|
||||
<div class="flex w-full flex-col">
|
||||
<div>{{ template.name }}</div>
|
||||
<div v-if="template.description" class="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
||||
{{ template.description }}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { ItemTemplateSummary } from "~~/lib/api/types/data-contracts";
|
||||
import MdiFileDocumentOutline from "~icons/mdi/file-document-outline";
|
||||
|
||||
type Props = {
|
||||
modelValue?: ItemTemplateSummary | null;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(["update:modelValue", "template-selected"]);
|
||||
|
||||
const { compact } = toRefs(props);
|
||||
const open = ref(false);
|
||||
const search = ref("");
|
||||
const id = useId();
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const { data: templates } = useAsyncData("templates-selector", async () => {
|
||||
const { data, error } = await api.templates.getAll();
|
||||
if (error) {
|
||||
return [];
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
function selectTemplate(template: ItemTemplateSummary) {
|
||||
if (value.value?.id !== template.id) {
|
||||
value.value = template;
|
||||
emit("template-selected", template);
|
||||
} else {
|
||||
value.value = null;
|
||||
emit("template-selected", null);
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
if (!templates.value) return [];
|
||||
const filtered = fuzzysort.go(search.value, templates.value, { key: "name", all: true }).map(i => i.obj);
|
||||
return filtered;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => value.value,
|
||||
() => {
|
||||
if (!value.value) {
|
||||
search.value = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -10,6 +10,7 @@ export enum DialogID {
|
||||
CreateLocation = "create-location",
|
||||
CreateLabel = "create-label",
|
||||
CreateNotifier = "create-notifier",
|
||||
CreateTemplate = "create-template",
|
||||
DuplicateSettings = "duplicate-settings",
|
||||
DuplicateTemporarySettings = "duplicate-temporary-settings",
|
||||
EditMaintenance = "edit-maintenance",
|
||||
@@ -23,6 +24,7 @@ export enum DialogID {
|
||||
PageQRCode = "page-qr-code",
|
||||
UpdateLabel = "update-label",
|
||||
UpdateLocation = "update-location",
|
||||
UpdateTemplate = "update-template",
|
||||
ItemChangeDetails = "item-table-updater",
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
import MdiWrench from "~icons/mdi/wrench";
|
||||
import MdiPlus from "~icons/mdi/plus";
|
||||
import MdiLogout from "~icons/mdi/logout";
|
||||
import MdiFileDocumentMultiple from "~icons/mdi/file-document-multiple";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -330,6 +331,13 @@
|
||||
name: computed(() => t("menu.search")),
|
||||
to: "/items",
|
||||
},
|
||||
{
|
||||
icon: MdiFileDocumentMultiple,
|
||||
id: 3,
|
||||
active: computed(() => route.path === "/templates"),
|
||||
name: computed(() => t("menu.templates")),
|
||||
to: "/templates",
|
||||
},
|
||||
{
|
||||
icon: MdiWrench,
|
||||
id: 4,
|
||||
|
||||
@@ -2,7 +2,13 @@ import { faker } from "@faker-js/faker";
|
||||
import { expect } from "vitest";
|
||||
import { overrideParts } from "../../base/urls";
|
||||
import { PublicApi } from "../../public";
|
||||
import type { ItemField, LabelCreate, LocationCreate, UserRegistration } from "../../types/data-contracts";
|
||||
import type {
|
||||
ItemField,
|
||||
ItemTemplateCreate,
|
||||
LabelCreate,
|
||||
LocationCreate,
|
||||
UserRegistration,
|
||||
} from "../../types/data-contracts";
|
||||
import * as config from "../../../../test/config";
|
||||
import { UserClient } from "../../user";
|
||||
import { Requests } from "../../../requests";
|
||||
@@ -49,6 +55,28 @@ function label(): LabelCreate {
|
||||
};
|
||||
}
|
||||
|
||||
function template(): ItemTemplateCreate {
|
||||
return {
|
||||
name: faker.lorem.words(2),
|
||||
description: faker.lorem.sentence(),
|
||||
notes: "",
|
||||
defaultQuantity: 1,
|
||||
defaultInsured: false,
|
||||
defaultName: faker.lorem.word(),
|
||||
defaultDescription: faker.lorem.sentence(),
|
||||
defaultManufacturer: faker.company.name(),
|
||||
defaultModelNumber: faker.string.alphanumeric(10),
|
||||
defaultLifetimeWarranty: false,
|
||||
defaultWarrantyDetails: "",
|
||||
defaultLocationId: null,
|
||||
defaultLabelIds: null,
|
||||
includeWarrantyFields: false,
|
||||
includePurchaseFields: false,
|
||||
includeSoldFields: false,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
function publicClient(): PublicApi {
|
||||
overrideParts(config.BASE_URL, "/api/v1");
|
||||
const requests = new Requests("");
|
||||
@@ -86,6 +114,7 @@ export const factories = {
|
||||
user,
|
||||
location,
|
||||
label,
|
||||
template,
|
||||
itemField,
|
||||
client: {
|
||||
public: publicClient,
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function sharedUserClient(): Promise<UserClient> {
|
||||
expect(loginResp.status).toBe(200);
|
||||
|
||||
cache.token = loginData.token;
|
||||
return factories.client.user(data.token);
|
||||
return factories.client.user(loginData.token);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
|
||||
297
frontend/lib/api/__test__/user/templates.test.ts
Normal file
297
frontend/lib/api/__test__/user/templates.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ItemTemplateOut } from "../../types/data-contracts";
|
||||
import type { UserClient } from "../../user";
|
||||
import { factories } from "../factories";
|
||||
import { sharedUserClient } from "../test-utils";
|
||||
|
||||
describe("templates lifecycle (create, update, delete)", () => {
|
||||
/**
|
||||
* useTemplate sets up a template resource for testing, and returns a function
|
||||
* that can be used to delete the template from the backend server.
|
||||
*/
|
||||
async function useTemplate(api: UserClient): Promise<[ItemTemplateOut, () => Promise<void>]> {
|
||||
const { response, data } = await api.templates.create(factories.template());
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const cleanup = async () => {
|
||||
const { response } = await api.templates.delete(data.id);
|
||||
expect(response.status).toBe(204);
|
||||
};
|
||||
|
||||
return [data, cleanup];
|
||||
}
|
||||
|
||||
test("user should be able to create a template", async () => {
|
||||
const api = await sharedUserClient();
|
||||
|
||||
const templateData = factories.template();
|
||||
|
||||
const { response, data } = await api.templates.create(templateData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.id).toBeTruthy();
|
||||
|
||||
// Ensure we can get the template
|
||||
const { response: getResponse, data: getData } = await api.templates.get(data.id);
|
||||
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getData.id).toBe(data.id);
|
||||
expect(getData.name).toBe(templateData.name);
|
||||
expect(getData.description).toBe(templateData.description);
|
||||
expect(getData.defaultQuantity).toBe(templateData.defaultQuantity);
|
||||
expect(getData.defaultInsured).toBe(templateData.defaultInsured);
|
||||
expect(getData.defaultName).toBe(templateData.defaultName);
|
||||
expect(getData.defaultDescription).toBe(templateData.defaultDescription);
|
||||
expect(getData.defaultManufacturer).toBe(templateData.defaultManufacturer);
|
||||
expect(getData.defaultModelNumber).toBe(templateData.defaultModelNumber);
|
||||
|
||||
// Cleanup
|
||||
const { response: deleteResponse } = await api.templates.delete(data.id);
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
});
|
||||
|
||||
test("user should be able to get all templates", async () => {
|
||||
const api = await sharedUserClient();
|
||||
const [_, cleanup] = await useTemplate(api);
|
||||
|
||||
const { response, data } = await api.templates.getAll();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("user should be able to update a template", async () => {
|
||||
const api = await sharedUserClient();
|
||||
const [template, cleanup] = await useTemplate(api);
|
||||
|
||||
const updateData = {
|
||||
id: template.id,
|
||||
name: "updated-template-name",
|
||||
description: "updated-description",
|
||||
notes: "updated-notes",
|
||||
defaultQuantity: 5,
|
||||
defaultInsured: true,
|
||||
defaultName: "Updated Default Name",
|
||||
defaultDescription: "Updated Default Description",
|
||||
defaultManufacturer: "Updated Manufacturer",
|
||||
defaultModelNumber: "MODEL-999",
|
||||
defaultLifetimeWarranty: true,
|
||||
defaultWarrantyDetails: "Lifetime coverage",
|
||||
defaultLocationId: null,
|
||||
defaultLabelIds: null,
|
||||
includeWarrantyFields: true,
|
||||
includePurchaseFields: true,
|
||||
includeSoldFields: false,
|
||||
fields: [],
|
||||
};
|
||||
|
||||
const { response, data } = await api.templates.update(template.id, updateData);
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.id).toBe(template.id);
|
||||
|
||||
// Ensure the template was updated
|
||||
const { response: getResponse, data: getData } = await api.templates.get(template.id);
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getData.name).toBe(updateData.name);
|
||||
expect(getData.description).toBe(updateData.description);
|
||||
expect(getData.notes).toBe(updateData.notes);
|
||||
expect(getData.defaultQuantity).toBe(updateData.defaultQuantity);
|
||||
expect(getData.defaultInsured).toBe(updateData.defaultInsured);
|
||||
expect(getData.defaultName).toBe(updateData.defaultName);
|
||||
expect(getData.defaultDescription).toBe(updateData.defaultDescription);
|
||||
expect(getData.defaultManufacturer).toBe(updateData.defaultManufacturer);
|
||||
expect(getData.defaultModelNumber).toBe(updateData.defaultModelNumber);
|
||||
expect(getData.defaultLifetimeWarranty).toBe(updateData.defaultLifetimeWarranty);
|
||||
expect(getData.includeWarrantyFields).toBe(updateData.includeWarrantyFields);
|
||||
expect(getData.includePurchaseFields).toBe(updateData.includePurchaseFields);
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("user should be able to delete a template", async () => {
|
||||
const api = await sharedUserClient();
|
||||
const [template, _] = await useTemplate(api);
|
||||
|
||||
const { response } = await api.templates.delete(template.id);
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Ensure we can't get the template
|
||||
const { response: getResponse } = await api.templates.get(template.id);
|
||||
expect(getResponse.status).toBe(404);
|
||||
});
|
||||
|
||||
test("user should be able to create a template with custom fields", async () => {
|
||||
const api = await sharedUserClient();
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
const templateData = factories.template();
|
||||
templateData.fields = [
|
||||
{ id: NIL_UUID, name: "Custom Field 1", type: "text", textValue: "Value 1" },
|
||||
{ id: NIL_UUID, name: "Custom Field 2", type: "text", textValue: "Value 2" },
|
||||
];
|
||||
|
||||
const { response, data } = await api.templates.create(templateData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.fields).toHaveLength(2);
|
||||
expect(data.fields![0]!.name).toBe("Custom Field 1");
|
||||
expect(data.fields![0]!.textValue).toBe("Value 1");
|
||||
expect(data.fields![1]!.name).toBe("Custom Field 2");
|
||||
expect(data.fields![1]!.textValue).toBe("Value 2");
|
||||
|
||||
// Cleanup
|
||||
const { response: deleteResponse } = await api.templates.delete(data.id);
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
});
|
||||
|
||||
test("user should be able to update template custom fields", async () => {
|
||||
const api = await sharedUserClient();
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
// Create template with a field
|
||||
const templateData = factories.template();
|
||||
templateData.fields = [{ id: NIL_UUID, name: "Original Field", type: "text", textValue: "Original Value" }];
|
||||
|
||||
const { response: createResponse, data: createdTemplate } = await api.templates.create(templateData);
|
||||
expect(createResponse.status).toBe(201);
|
||||
expect(createdTemplate.fields).toHaveLength(1);
|
||||
|
||||
// Update with modified and new fields
|
||||
const updateData = {
|
||||
id: createdTemplate.id,
|
||||
name: createdTemplate.name,
|
||||
description: createdTemplate.description,
|
||||
notes: createdTemplate.notes,
|
||||
defaultQuantity: createdTemplate.defaultQuantity,
|
||||
defaultInsured: createdTemplate.defaultInsured,
|
||||
defaultName: createdTemplate.defaultName,
|
||||
defaultDescription: createdTemplate.defaultDescription,
|
||||
defaultManufacturer: createdTemplate.defaultManufacturer,
|
||||
defaultModelNumber: createdTemplate.defaultModelNumber,
|
||||
defaultLifetimeWarranty: createdTemplate.defaultLifetimeWarranty,
|
||||
defaultWarrantyDetails: createdTemplate.defaultWarrantyDetails,
|
||||
defaultLocationId: null,
|
||||
defaultLabelIds: null,
|
||||
includeWarrantyFields: createdTemplate.includeWarrantyFields,
|
||||
includePurchaseFields: createdTemplate.includePurchaseFields,
|
||||
includeSoldFields: createdTemplate.includeSoldFields,
|
||||
fields: [
|
||||
{ id: createdTemplate.fields![0]!.id, name: "Updated Field", type: "text", textValue: "Updated Value" },
|
||||
{ id: NIL_UUID, name: "New Field", type: "text", textValue: "New Value" },
|
||||
],
|
||||
};
|
||||
|
||||
const { response: updateResponse, data: updatedTemplate } = await api.templates.update(
|
||||
createdTemplate.id,
|
||||
updateData
|
||||
);
|
||||
expect(updateResponse.status).toBe(200);
|
||||
expect(updatedTemplate.fields).toHaveLength(2);
|
||||
|
||||
// Cleanup
|
||||
const { response: deleteResponse } = await api.templates.delete(createdTemplate.id);
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe("templates with location and labels", () => {
|
||||
test("user should be able to create a template with a default location", async () => {
|
||||
const api = await sharedUserClient();
|
||||
|
||||
// First create a location
|
||||
const locationData = factories.location();
|
||||
const { response: locResponse, data: location } = await api.locations.create(locationData);
|
||||
expect(locResponse.status).toBe(201);
|
||||
|
||||
// Create template with the location
|
||||
const templateData = factories.template();
|
||||
templateData.defaultLocationId = location.id;
|
||||
|
||||
const { response, data } = await api.templates.create(templateData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.defaultLocation).toBeTruthy();
|
||||
expect(data.defaultLocation?.id).toBe(location.id);
|
||||
expect(data.defaultLocation?.name).toBe(location.name);
|
||||
|
||||
// Cleanup
|
||||
await api.templates.delete(data.id);
|
||||
await api.locations.delete(location.id);
|
||||
});
|
||||
|
||||
test("user should be able to create a template with default labels", async () => {
|
||||
const api = await sharedUserClient();
|
||||
|
||||
// First create some labels
|
||||
const { response: label1Response, data: label1 } = await api.labels.create(factories.label());
|
||||
expect(label1Response.status).toBe(201);
|
||||
|
||||
const { response: label2Response, data: label2 } = await api.labels.create(factories.label());
|
||||
expect(label2Response.status).toBe(201);
|
||||
|
||||
// Create template with labels
|
||||
const templateData = factories.template();
|
||||
templateData.defaultLabelIds = [label1.id, label2.id];
|
||||
|
||||
const { response, data } = await api.templates.create(templateData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.defaultLabels).toHaveLength(2);
|
||||
expect(data.defaultLabels.map(l => l.id)).toContain(label1.id);
|
||||
expect(data.defaultLabels.map(l => l.id)).toContain(label2.id);
|
||||
|
||||
// Cleanup
|
||||
await api.templates.delete(data.id);
|
||||
await api.labels.delete(label1.id);
|
||||
await api.labels.delete(label2.id);
|
||||
});
|
||||
|
||||
test("user should be able to update template to remove location", async () => {
|
||||
const api = await sharedUserClient();
|
||||
|
||||
// Create a location
|
||||
const { response: locResponse, data: location } = await api.locations.create(factories.location());
|
||||
expect(locResponse.status).toBe(201);
|
||||
|
||||
// Create template with location
|
||||
const templateData = factories.template();
|
||||
templateData.defaultLocationId = location.id;
|
||||
|
||||
const { response: createResponse, data: template } = await api.templates.create(templateData);
|
||||
expect(createResponse.status).toBe(201);
|
||||
expect(template.defaultLocation).toBeTruthy();
|
||||
|
||||
// Update to remove location
|
||||
const updateData = {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
notes: template.notes,
|
||||
defaultQuantity: template.defaultQuantity,
|
||||
defaultInsured: template.defaultInsured,
|
||||
defaultName: template.defaultName,
|
||||
defaultDescription: template.defaultDescription,
|
||||
defaultManufacturer: template.defaultManufacturer,
|
||||
defaultModelNumber: template.defaultModelNumber,
|
||||
defaultLifetimeWarranty: template.defaultLifetimeWarranty,
|
||||
defaultWarrantyDetails: template.defaultWarrantyDetails,
|
||||
defaultLocationId: null,
|
||||
defaultLabelIds: null,
|
||||
includeWarrantyFields: template.includeWarrantyFields,
|
||||
includePurchaseFields: template.includePurchaseFields,
|
||||
includeSoldFields: template.includeSoldFields,
|
||||
fields: [],
|
||||
};
|
||||
|
||||
const { response: updateResponse, data: updated } = await api.templates.update(template.id, updateData);
|
||||
expect(updateResponse.status).toBe(200);
|
||||
expect(updated.defaultLocation).toBeNull();
|
||||
|
||||
// Cleanup
|
||||
await api.templates.delete(template.id);
|
||||
await api.locations.delete(location.id);
|
||||
});
|
||||
});
|
||||
38
frontend/lib/api/classes/templates.ts
Normal file
38
frontend/lib/api/classes/templates.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { BaseAPI, route } from "../base";
|
||||
import type {
|
||||
ItemTemplateCreate,
|
||||
ItemTemplateOut,
|
||||
ItemTemplateSummary,
|
||||
ItemTemplateUpdate,
|
||||
ItemTemplateCreateItemRequest,
|
||||
ItemOut,
|
||||
} from "../types/data-contracts";
|
||||
|
||||
export class TemplatesApi extends BaseAPI {
|
||||
getAll() {
|
||||
return this.http.get<ItemTemplateSummary[]>({ url: route("/templates") });
|
||||
}
|
||||
|
||||
create(body: ItemTemplateCreate) {
|
||||
return this.http.post<ItemTemplateCreate, ItemTemplateOut>({ url: route("/templates"), body });
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.http.get<ItemTemplateOut>({ url: route(`/templates/${id}`) });
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
return this.http.delete<void>({ url: route(`/templates/${id}`) });
|
||||
}
|
||||
|
||||
update(id: string, body: ItemTemplateUpdate) {
|
||||
return this.http.put<ItemTemplateUpdate, ItemTemplateOut>({ url: route(`/templates/${id}`), body });
|
||||
}
|
||||
|
||||
createItem(templateId: string, body: ItemTemplateCreateItemRequest) {
|
||||
return this.http.post<ItemTemplateCreateItemRequest, ItemOut>({
|
||||
url: route(`/templates/${templateId}/create-item`),
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
242
frontend/lib/api/types/data-contracts.ts
generated
242
frontend/lib/api/types/data-contracts.ts
generated
@@ -17,6 +17,10 @@ export enum UserRole {
|
||||
RoleOwner = "owner",
|
||||
}
|
||||
|
||||
export enum TemplatefieldType {
|
||||
TypeText = "text",
|
||||
}
|
||||
|
||||
export enum MaintenanceFilterStatus {
|
||||
MaintenanceFilterStatusScheduled = "scheduled",
|
||||
MaintenanceFilterStatusCompleted = "completed",
|
||||
@@ -154,6 +158,8 @@ export interface EntGroup {
|
||||
export interface EntGroupEdges {
|
||||
/** InvitationTokens holds the value of the invitation_tokens edge. */
|
||||
invitation_tokens: EntGroupInvitationToken[];
|
||||
/** ItemTemplates holds the value of the item_templates edge. */
|
||||
item_templates: EntItemTemplate[];
|
||||
/** Items holds the value of the items edge. */
|
||||
items: EntItem[];
|
||||
/** Labels holds the value of the labels edge. */
|
||||
@@ -301,6 +307,59 @@ export interface EntItemFieldEdges {
|
||||
item: EntItem;
|
||||
}
|
||||
|
||||
export interface EntItemTemplate {
|
||||
/** CreatedAt holds the value of the "created_at" field. */
|
||||
created_at: string;
|
||||
/** Default description for items created from this template */
|
||||
default_description: string;
|
||||
/** DefaultInsured holds the value of the "default_insured" field. */
|
||||
default_insured: boolean;
|
||||
/** Default label IDs for items created from this template */
|
||||
default_label_ids: string[];
|
||||
/** DefaultLifetimeWarranty holds the value of the "default_lifetime_warranty" field. */
|
||||
default_lifetime_warranty: boolean;
|
||||
/** DefaultManufacturer holds the value of the "default_manufacturer" field. */
|
||||
default_manufacturer: string;
|
||||
/** Default model number for items created from this template */
|
||||
default_model_number: string;
|
||||
/** Default name template for items (can use placeholders) */
|
||||
default_name: string;
|
||||
/** DefaultQuantity holds the value of the "default_quantity" field. */
|
||||
default_quantity: number;
|
||||
/** DefaultWarrantyDetails holds the value of the "default_warranty_details" field. */
|
||||
default_warranty_details: string;
|
||||
/** Description holds the value of the "description" field. */
|
||||
description: string;
|
||||
/**
|
||||
* Edges holds the relations/edges for other nodes in the graph.
|
||||
* The values are being populated by the ItemTemplateQuery when eager-loading is set.
|
||||
*/
|
||||
edges: EntItemTemplateEdges;
|
||||
/** ID of the ent. */
|
||||
id: string;
|
||||
/** Whether to include purchase fields in items created from this template */
|
||||
include_purchase_fields: boolean;
|
||||
/** Whether to include sold fields in items created from this template */
|
||||
include_sold_fields: boolean;
|
||||
/** Whether to include warranty fields in items created from this template */
|
||||
include_warranty_fields: boolean;
|
||||
/** Name holds the value of the "name" field. */
|
||||
name: string;
|
||||
/** Notes holds the value of the "notes" field. */
|
||||
notes: string;
|
||||
/** UpdatedAt holds the value of the "updated_at" field. */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface EntItemTemplateEdges {
|
||||
/** Fields holds the value of the fields edge. */
|
||||
fields: EntTemplateField[];
|
||||
/** Group holds the value of the group edge. */
|
||||
group: EntGroup;
|
||||
/** Location holds the value of the location edge. */
|
||||
location: EntLocation;
|
||||
}
|
||||
|
||||
export interface EntLabel {
|
||||
/** Color holds the value of the "color" field. */
|
||||
color: string;
|
||||
@@ -417,6 +476,33 @@ export interface EntNotifierEdges {
|
||||
user: EntUser;
|
||||
}
|
||||
|
||||
export interface EntTemplateField {
|
||||
/** CreatedAt holds the value of the "created_at" field. */
|
||||
created_at: string;
|
||||
/** Description holds the value of the "description" field. */
|
||||
description: string;
|
||||
/**
|
||||
* Edges holds the relations/edges for other nodes in the graph.
|
||||
* The values are being populated by the TemplateFieldQuery when eager-loading is set.
|
||||
*/
|
||||
edges: EntTemplateFieldEdges;
|
||||
/** ID of the ent. */
|
||||
id: string;
|
||||
/** Name holds the value of the "name" field. */
|
||||
name: string;
|
||||
/** TextValue holds the value of the "text_value" field. */
|
||||
text_value: string;
|
||||
/** Type holds the value of the "type" field. */
|
||||
type: TemplatefieldType;
|
||||
/** UpdatedAt holds the value of the "updated_at" field. */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface EntTemplateFieldEdges {
|
||||
/** ItemTemplate holds the value of the item_template edge. */
|
||||
item_template: EntItemTemplate;
|
||||
}
|
||||
|
||||
export interface EntUser {
|
||||
/** ActivatedOn holds the value of the "activated_on" field. */
|
||||
activated_on: string;
|
||||
@@ -435,6 +521,10 @@ export interface EntUser {
|
||||
is_superuser: boolean;
|
||||
/** Name holds the value of the "name" field. */
|
||||
name: string;
|
||||
/** OidcIssuer holds the value of the "oidc_issuer" field. */
|
||||
oidc_issuer: string;
|
||||
/** OidcSubject holds the value of the "oidc_subject" field. */
|
||||
oidc_subject: string;
|
||||
/** Role holds the value of the "role" field. */
|
||||
role: UserRole;
|
||||
/** Superuser holds the value of the "superuser" field. */
|
||||
@@ -610,6 +700,112 @@ export interface ItemSummary {
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface ItemTemplateCreate {
|
||||
/** @maxLength 1000 */
|
||||
defaultDescription?: string | null;
|
||||
defaultInsured: boolean;
|
||||
defaultLabelIds?: string[] | null;
|
||||
defaultLifetimeWarranty: boolean;
|
||||
/** Default location and labels */
|
||||
defaultLocationId?: string | null;
|
||||
/** @maxLength 255 */
|
||||
defaultManufacturer?: string | null;
|
||||
/** @maxLength 255 */
|
||||
defaultModelNumber?: string | null;
|
||||
/** @maxLength 255 */
|
||||
defaultName?: string | null;
|
||||
/** Default values for items */
|
||||
defaultQuantity?: number | null;
|
||||
/** @maxLength 1000 */
|
||||
defaultWarrantyDetails?: string | null;
|
||||
/** @maxLength 1000 */
|
||||
description: string;
|
||||
/** Custom fields */
|
||||
fields: TemplateField[];
|
||||
includePurchaseFields: boolean;
|
||||
includeSoldFields: boolean;
|
||||
/** Metadata flags */
|
||||
includeWarrantyFields: boolean;
|
||||
/**
|
||||
* @minLength 1
|
||||
* @maxLength 255
|
||||
*/
|
||||
name: string;
|
||||
/** @maxLength 1000 */
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface ItemTemplateOut {
|
||||
createdAt: Date | string;
|
||||
defaultDescription: string;
|
||||
defaultInsured: boolean;
|
||||
defaultLabels: TemplateLabelSummary[];
|
||||
defaultLifetimeWarranty: boolean;
|
||||
/** Default location and labels */
|
||||
defaultLocation: TemplateLocationSummary;
|
||||
defaultManufacturer: string;
|
||||
defaultModelNumber: string;
|
||||
defaultName: string;
|
||||
/** Default values for items */
|
||||
defaultQuantity: number;
|
||||
defaultWarrantyDetails: string;
|
||||
description: string;
|
||||
/** Custom fields */
|
||||
fields: TemplateField[];
|
||||
id: string;
|
||||
includePurchaseFields: boolean;
|
||||
includeSoldFields: boolean;
|
||||
/** Metadata flags */
|
||||
includeWarrantyFields: boolean;
|
||||
name: string;
|
||||
notes: string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface ItemTemplateSummary {
|
||||
createdAt: Date | string;
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface ItemTemplateUpdate {
|
||||
/** @maxLength 1000 */
|
||||
defaultDescription?: string | null;
|
||||
defaultInsured: boolean;
|
||||
defaultLabelIds?: string[] | null;
|
||||
defaultLifetimeWarranty: boolean;
|
||||
/** Default location and labels */
|
||||
defaultLocationId?: string | null;
|
||||
/** @maxLength 255 */
|
||||
defaultManufacturer?: string | null;
|
||||
/** @maxLength 255 */
|
||||
defaultModelNumber?: string | null;
|
||||
/** @maxLength 255 */
|
||||
defaultName?: string | null;
|
||||
/** Default values for items */
|
||||
defaultQuantity?: number | null;
|
||||
/** @maxLength 1000 */
|
||||
defaultWarrantyDetails?: string | null;
|
||||
/** @maxLength 1000 */
|
||||
description: string;
|
||||
/** Custom fields */
|
||||
fields: TemplateField[];
|
||||
id: string;
|
||||
includePurchaseFields: boolean;
|
||||
includeSoldFields: boolean;
|
||||
/** Metadata flags */
|
||||
includeWarrantyFields: boolean;
|
||||
/**
|
||||
* @minLength 1
|
||||
* @maxLength 255
|
||||
*/
|
||||
name: string;
|
||||
/** @maxLength 1000 */
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface ItemUpdate {
|
||||
archived: boolean;
|
||||
assetId: string;
|
||||
@@ -800,6 +996,23 @@ export interface PaginationResultItemSummary {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TemplateField {
|
||||
id: string;
|
||||
name: string;
|
||||
textValue: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TemplateLabelSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TemplateLocationSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TotalsByOrganizer {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -821,6 +1034,8 @@ export interface UserOut {
|
||||
isOwner: boolean;
|
||||
isSuperuser: boolean;
|
||||
name: string;
|
||||
oidcIssuer: string;
|
||||
oidcSubject: string;
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
@@ -862,12 +1077,7 @@ export interface APISummary {
|
||||
labelPrinting: boolean;
|
||||
latest: Latest;
|
||||
message: string;
|
||||
oidc?: {
|
||||
enabled: boolean;
|
||||
autoRedirect?: boolean;
|
||||
allowLocal?: boolean;
|
||||
buttonText?: string;
|
||||
};
|
||||
oidc: OIDCStatus;
|
||||
title: string;
|
||||
versions: string[];
|
||||
}
|
||||
@@ -906,6 +1116,19 @@ export interface ItemAttachmentToken {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ItemTemplateCreateItemRequest {
|
||||
/** @maxLength 1000 */
|
||||
description: string;
|
||||
labelIds: string[];
|
||||
locationId: string;
|
||||
/**
|
||||
* @minLength 1
|
||||
* @maxLength 255
|
||||
*/
|
||||
name: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface LoginForm {
|
||||
/** @example "admin" */
|
||||
password: string;
|
||||
@@ -914,6 +1137,13 @@ export interface LoginForm {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface OIDCStatus {
|
||||
allowLocal: boolean;
|
||||
autoRedirect: boolean;
|
||||
buttonText: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
attachmentToken: string;
|
||||
expiresAt: Date | string;
|
||||
|
||||
@@ -11,12 +11,14 @@ import { ReportsAPI } from "./classes/reports";
|
||||
import { NotifiersAPI } from "./classes/notifiers";
|
||||
import { MaintenanceAPI } from "./classes/maintenance";
|
||||
import { ProductAPI } from "./classes/product";
|
||||
import { TemplatesApi } from "./classes/templates";
|
||||
import type { Requests } from "~~/lib/requests";
|
||||
|
||||
export class UserClient extends BaseAPI {
|
||||
locations: LocationsApi;
|
||||
labels: LabelsApi;
|
||||
items: ItemsApi;
|
||||
templates: TemplatesApi;
|
||||
maintenance: MaintenanceAPI;
|
||||
group: GroupApi;
|
||||
user: UserApi;
|
||||
@@ -33,6 +35,7 @@ export class UserClient extends BaseAPI {
|
||||
this.locations = new LocationsApi(requests);
|
||||
this.labels = new LabelsApi(requests);
|
||||
this.items = new ItemsApi(requests, attachmentToken);
|
||||
this.templates = new TemplatesApi(requests);
|
||||
this.maintenance = new MaintenanceAPI(requests);
|
||||
this.group = new GroupApi(requests);
|
||||
this.user = new UserApi(requests);
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"rotate_photo": "Rotate photo",
|
||||
"set_as_primary_photo": "Set as { isPrimary, select, true {non-} false {} other {}}primary photo",
|
||||
"title": "Create Item",
|
||||
"clear_template": "Clear Template",
|
||||
"toast": {
|
||||
"already_creating": "Already creating an item",
|
||||
"create_failed": "Couldn't create item",
|
||||
@@ -229,6 +230,65 @@
|
||||
"quick_menu": {
|
||||
"no_results": "No results found.",
|
||||
"shortcut_hint": "Use the number keys to quickly select an action."
|
||||
},
|
||||
"template": {
|
||||
"card": {
|
||||
"delete": "Delete template",
|
||||
"duplicate": "Duplicate template",
|
||||
"edit": "Edit template"
|
||||
},
|
||||
"confirm_delete": "Delete this template?",
|
||||
"create_modal": {
|
||||
"title": "Create Template"
|
||||
},
|
||||
"detail": {
|
||||
"default_values": "Default Values",
|
||||
"updated": "Updated"
|
||||
},
|
||||
"edit_modal": {
|
||||
"title": "Edit Template"
|
||||
},
|
||||
"form": {
|
||||
"custom_fields": "Custom Fields",
|
||||
"default_item_values": "Default Item Values",
|
||||
"default_location": "Default Location",
|
||||
"default_value": "Default Value",
|
||||
"field_name": "Field Name",
|
||||
"item_description": "Item Description",
|
||||
"item_name": "Item Name",
|
||||
"lifetime_warranty": "Lifetime Warranty",
|
||||
"location": "Location",
|
||||
"manufacturer": "Manufacturer",
|
||||
"model_number": "Model Number",
|
||||
"no_custom_fields": "No custom fields.",
|
||||
"template_description": "Template Description",
|
||||
"template_name": "Template Name"
|
||||
},
|
||||
"selector": {
|
||||
"label": "Template (Optional)",
|
||||
"not_found": "No template found",
|
||||
"search": "Search templates...",
|
||||
"select": "Select template..."
|
||||
},
|
||||
"toast": {
|
||||
"applied": "Template \"{name}\" applied",
|
||||
"create_failed": "Failed to create template",
|
||||
"created": "Template created",
|
||||
"delete_failed": "Failed to delete template",
|
||||
"deleted": "Template deleted",
|
||||
"duplicate_failed": "Failed to duplicate template",
|
||||
"duplicated": "Template duplicated as \"{name}\"",
|
||||
"load_failed": "Failed to load template details",
|
||||
"saved_as_template": "Item saved as template \"{name}\"",
|
||||
"update_failed": "Failed to update template",
|
||||
"updated": "Template updated"
|
||||
},
|
||||
"apply_template": "Apply a template",
|
||||
"using_template": "Using template: {name}",
|
||||
"show_defaults": "Show defaults",
|
||||
"hide_defaults": "Hide defaults",
|
||||
"empty_value": "(empty)",
|
||||
"save_as_template": "Save as Template"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -280,7 +340,9 @@
|
||||
"value": "Value",
|
||||
"version": "Version: { version }",
|
||||
"welcome": "Welcome, { username }",
|
||||
"preview": "Preview"
|
||||
"preview": "Preview",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"home": {
|
||||
"labels": "Labels",
|
||||
@@ -553,8 +615,15 @@
|
||||
"profile": "Profile",
|
||||
"scanner": "Scanner",
|
||||
"search": "Search",
|
||||
"templates": "Templates",
|
||||
"tools": "Tools"
|
||||
},
|
||||
"pages": {
|
||||
"templates": {
|
||||
"title": "Templates",
|
||||
"no_templates": "No templates yet."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"active": "Active",
|
||||
"change_password": "Change Password",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt generate",
|
||||
"dev": "nuxt dev",
|
||||
|
||||
@@ -9,7 +9,16 @@
|
||||
import MdiMinus from "~icons/mdi/minus";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import MdiPlusBoxMultipleOutline from "~icons/mdi/plus-box-multiple-outline";
|
||||
import MdiContentSaveEdit from "~icons/mdi/content-save-edit";
|
||||
import MdiDotsVertical from "~icons/mdi/dots-vertical";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -556,6 +565,53 @@
|
||||
navigateTo("/home");
|
||||
}
|
||||
|
||||
async function saveAsTemplate() {
|
||||
if (!item.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
// Create template from item data
|
||||
const templateData = {
|
||||
name: `Template: ${item.value.name}`,
|
||||
description: "",
|
||||
notes: "",
|
||||
defaultName: item.value.name,
|
||||
defaultDescription: item.value.description || "",
|
||||
defaultQuantity: item.value.quantity,
|
||||
defaultInsured: item.value.insured,
|
||||
defaultManufacturer: item.value.manufacturer || "",
|
||||
defaultModelNumber: item.value.modelNumber || "",
|
||||
defaultLifetimeWarranty: item.value.lifetimeWarranty,
|
||||
defaultWarrantyDetails: item.value.warrantyDetails || "",
|
||||
defaultLocationId: item.value.location?.id || "",
|
||||
defaultLabelIds: item.value.labels?.map(l => l.id) || [],
|
||||
includeWarrantyFields: !!(
|
||||
item.value.warrantyDetails ||
|
||||
item.value.lifetimeWarranty ||
|
||||
item.value.warrantyExpires
|
||||
),
|
||||
includePurchaseFields: !!(item.value.purchaseFrom || item.value.purchasePrice || item.value.purchaseTime),
|
||||
includeSoldFields: !!(item.value.soldTo || item.value.soldPrice || item.value.soldTime),
|
||||
fields: item.value.fields.map(field => ({
|
||||
id: NIL_UUID,
|
||||
name: field.name,
|
||||
type: "text",
|
||||
textValue: field.textValue || "",
|
||||
})),
|
||||
};
|
||||
|
||||
const { data, error } = await api.templates.create(templateData);
|
||||
if (error) {
|
||||
toast.error(t("components.template.toast.create_failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("components.template.toast.saved_as_template", { name: templateData.name }));
|
||||
navigateTo(`/template/${data.id}`);
|
||||
}
|
||||
|
||||
async function createSubitem() {
|
||||
// setting URL Parameter that is read and immidiately removed in the Item-CreateModal
|
||||
await router.push({
|
||||
@@ -640,7 +696,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto mt-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="ml-auto mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<LabelMaker
|
||||
v-if="typeof item.assetId === 'string' && item.assetId != ''"
|
||||
:id="item.assetId"
|
||||
@@ -651,14 +707,30 @@
|
||||
<MdiPlus />
|
||||
<span class="hidden md:inline">{{ $t("global.create_subitem") }}</span>
|
||||
</Button>
|
||||
<Button class="w-9 md:w-auto" :aria-label="$t('global.duplicate')" @click="handleDuplicateClick">
|
||||
<MdiPlusBoxMultipleOutline />
|
||||
<span class="hidden md:inline">{{ $t("global.duplicate") }}</span>
|
||||
</Button>
|
||||
<Button variant="destructive" class="w-9 md:w-auto" :aria-label="$t('global.delete')" @click="deleteItem">
|
||||
<MdiDelete />
|
||||
<span class="hidden md:inline">{{ $t("global.delete") }}</span>
|
||||
</Button>
|
||||
|
||||
<!-- More actions dropdown -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="icon" :aria-label="$t('global.more_actions')">
|
||||
<MdiDotsVertical class="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<DropdownMenuItem @click="handleDuplicateClick">
|
||||
<MdiPlusBoxMultipleOutline class="mr-2 size-4" />
|
||||
{{ $t("global.duplicate") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="saveAsTemplate">
|
||||
<MdiContentSaveEdit class="mr-2 size-4" />
|
||||
{{ $t("components.template.save_as_template") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="deleteItem">
|
||||
<MdiDelete class="mr-2 size-4" />
|
||||
{{ $t("global.delete") }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
333
frontend/pages/template/[id].vue
Normal file
333
frontend/pages/template/[id].vue
Normal file
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import MdiPencil from "~icons/mdi/pencil";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import MdiPlus from "~icons/mdi/plus";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
import Markdown from "~/components/global/Markdown.vue";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import LabelSelector from "~/components/Label/Selector.vue";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import type { LocationOut } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
});
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const route = useRoute();
|
||||
const api = useUserApi();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const labelStore = useLabelStore();
|
||||
const labels = computed(() => labelStore.labels);
|
||||
|
||||
const templateId = computed<string>(() => route.params.id as string);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data: template, refresh } = useAsyncData(templateId.value, async () => {
|
||||
const { data, error } = await api.templates.get(templateId.value);
|
||||
if (error) {
|
||||
toast.error(t("components.template.toast.load_failed"));
|
||||
navigateTo("/templates");
|
||||
return;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
async function confirmDelete() {
|
||||
const { isCanceled } = await confirm.open(t("components.template.confirm_delete"));
|
||||
if (isCanceled) return;
|
||||
|
||||
const { error } = await api.templates.delete(templateId.value);
|
||||
if (error) {
|
||||
toast.error(t("components.template.toast.delete_failed"));
|
||||
return;
|
||||
}
|
||||
toast.success(t("components.template.toast.deleted"));
|
||||
navigateTo("/templates");
|
||||
}
|
||||
|
||||
const updating = ref(false);
|
||||
const updateData = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
notes: "",
|
||||
defaultName: "",
|
||||
defaultDescription: "",
|
||||
defaultQuantity: 1,
|
||||
defaultInsured: false,
|
||||
defaultManufacturer: "",
|
||||
defaultModelNumber: "",
|
||||
defaultLifetimeWarranty: false,
|
||||
defaultWarrantyDetails: "",
|
||||
defaultLocation: null as LocationOut | null,
|
||||
defaultLabelIds: [] as string[],
|
||||
includeWarrantyFields: false,
|
||||
includePurchaseFields: false,
|
||||
includeSoldFields: false,
|
||||
fields: [] as Array<{ id: string; name: string; type: "text"; textValue: string }>,
|
||||
});
|
||||
|
||||
function openUpdate() {
|
||||
if (!template.value) return;
|
||||
Object.assign(updateData, {
|
||||
id: template.value.id,
|
||||
name: template.value.name,
|
||||
description: template.value.description,
|
||||
notes: template.value.notes,
|
||||
defaultName: template.value.defaultName ?? "",
|
||||
defaultDescription: template.value.defaultDescription ?? "",
|
||||
defaultQuantity: template.value.defaultQuantity,
|
||||
defaultInsured: template.value.defaultInsured,
|
||||
defaultManufacturer: template.value.defaultManufacturer,
|
||||
defaultModelNumber: template.value.defaultModelNumber ?? "",
|
||||
defaultLifetimeWarranty: template.value.defaultLifetimeWarranty,
|
||||
defaultWarrantyDetails: template.value.defaultWarrantyDetails,
|
||||
defaultLocation: template.value.defaultLocation ?? null,
|
||||
defaultLabelIds: template.value.defaultLabels?.map(l => l.id) ?? [],
|
||||
includeWarrantyFields: template.value.includeWarrantyFields,
|
||||
includePurchaseFields: template.value.includePurchaseFields,
|
||||
includeSoldFields: template.value.includeSoldFields,
|
||||
fields: template.value.fields.map(f => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
type: "text" as const,
|
||||
textValue: f.textValue,
|
||||
})),
|
||||
});
|
||||
openDialog(DialogID.UpdateTemplate);
|
||||
}
|
||||
|
||||
async function update() {
|
||||
updating.value = true;
|
||||
|
||||
// Prepare the data with proper format for API
|
||||
const payload = {
|
||||
...updateData,
|
||||
defaultLocationId: updateData.defaultLocation?.id ?? "",
|
||||
};
|
||||
|
||||
const { error, data } = await api.templates.update(templateId.value, payload);
|
||||
if (error) {
|
||||
updating.value = false;
|
||||
toast.error(t("components.template.toast.update_failed"));
|
||||
return;
|
||||
}
|
||||
toast.success(t("components.template.toast.updated"));
|
||||
template.value = data;
|
||||
closeDialog(DialogID.UpdateTemplate);
|
||||
updating.value = false;
|
||||
refresh();
|
||||
}
|
||||
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :dialog-id="DialogID.UpdateTemplate">
|
||||
<DialogContent class="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.template.edit_modal.title") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form v-if="template" class="flex flex-col gap-2" @submit.prevent="update">
|
||||
<FormTextField
|
||||
v-model="updateData.name"
|
||||
:autofocus="true"
|
||||
:label="$t('components.template.form.template_name')"
|
||||
:max-length="255"
|
||||
/>
|
||||
<FormTextArea
|
||||
v-model="updateData.description"
|
||||
:label="$t('components.template.form.template_description')"
|
||||
:max-length="1000"
|
||||
/>
|
||||
|
||||
<Separator class="my-2" />
|
||||
<h3 class="text-sm font-medium">{{ $t("components.template.form.default_item_values") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<FormTextField
|
||||
v-model="updateData.defaultName"
|
||||
:label="$t('components.template.form.item_name')"
|
||||
:max-length="255"
|
||||
/>
|
||||
<FormTextArea
|
||||
v-model="updateData.defaultDescription"
|
||||
:label="$t('components.template.form.item_description')"
|
||||
:max-length="1000"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormTextField
|
||||
v-model.number="updateData.defaultQuantity"
|
||||
:label="$t('global.quantity')"
|
||||
type="number"
|
||||
:min="1"
|
||||
/>
|
||||
<FormTextField
|
||||
v-model="updateData.defaultModelNumber"
|
||||
:label="$t('components.template.form.model_number')"
|
||||
:max-length="255"
|
||||
/>
|
||||
</div>
|
||||
<FormTextField
|
||||
v-model="updateData.defaultManufacturer"
|
||||
:label="$t('components.template.form.manufacturer')"
|
||||
:max-length="255"
|
||||
/>
|
||||
<LocationSelector
|
||||
v-model="updateData.defaultLocation"
|
||||
:label="$t('components.template.form.default_location')"
|
||||
/>
|
||||
<LabelSelector v-model="updateData.defaultLabelIds" :labels="labels ?? []" />
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="editInsured" v-model:checked="updateData.defaultInsured" />
|
||||
<Label for="editInsured" class="text-sm">{{ $t("global.insured") }}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="editWarranty" v-model:checked="updateData.defaultLifetimeWarranty" />
|
||||
<Label for="editWarranty" class="text-sm">{{ $t("components.template.form.lifetime_warranty") }}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="my-2" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">{{ $t("components.template.form.custom_fields") }}</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="updateData.fields.push({ id: NIL_UUID, name: '', type: 'text', textValue: '' })"
|
||||
>
|
||||
<MdiPlus class="mr-1 size-4" />
|
||||
{{ $t("global.add") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="updateData.fields.length > 0" class="flex flex-col gap-2">
|
||||
<div v-for="(field, idx) in updateData.fields" :key="idx" class="flex items-end gap-2">
|
||||
<FormTextField
|
||||
v-model="field.name"
|
||||
:label="$t('components.template.form.field_name')"
|
||||
:max-length="255"
|
||||
class="flex-1"
|
||||
/>
|
||||
<FormTextField
|
||||
v-model="field.textValue"
|
||||
:label="$t('components.template.form.default_value')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button type="button" size="icon" variant="ghost" @click="updateData.fields.splice(idx, 1)">
|
||||
<MdiDelete class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" :loading="updating">{{ $t("global.update") }}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<BaseContainer v-if="template">
|
||||
<Title>{{ template.name }}</Title>
|
||||
|
||||
<Card class="p-3">
|
||||
<header :class="{ 'mb-2': template.description }">
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<div>
|
||||
<h1 class="pb-1 text-2xl">{{ template.name }}</h1>
|
||||
<div class="flex flex-wrap gap-1 text-xs text-muted-foreground">
|
||||
<span>{{ $t("global.created") }} <DateTime :date="template.createdAt" /></span>
|
||||
<span>•</span>
|
||||
<span>{{ $t("components.template.detail.updated") }} <DateTime :date="template.updatedAt" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<Button @click="openUpdate">
|
||||
<MdiPencil class="mr-1" />
|
||||
{{ $t("global.edit") }}
|
||||
</Button>
|
||||
<Button variant="destructive" @click="confirmDelete">
|
||||
<MdiDelete class="mr-1" />
|
||||
{{ $t("global.delete") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Separator v-if="template.description" class="my-3" />
|
||||
<Markdown v-if="template.description" :source="template.description" />
|
||||
|
||||
<Separator class="my-3" />
|
||||
<div class="grid gap-4 text-sm md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-2 font-medium">{{ $t("components.template.detail.default_values") }}</h3>
|
||||
<dl class="flex flex-col gap-1">
|
||||
<div v-if="template.defaultName" class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ $t("components.template.form.item_name") }}</dt>
|
||||
<dd>{{ template.defaultName }}</dd>
|
||||
</div>
|
||||
<div v-if="template.defaultDescription" class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ $t("components.template.form.item_description") }}</dt>
|
||||
<dd class="max-w-[200px] truncate">{{ template.defaultDescription }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ $t("global.quantity") }}</dt>
|
||||
<dd>{{ template.defaultQuantity }}</dd>
|
||||
</div>
|
||||
<div v-if="template.defaultModelNumber" class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ $t("components.template.form.model_number") }}</dt>
|
||||
<dd>{{ template.defaultModelNumber }}</dd>
|
||||
</div>
|
||||
<div v-if="template.defaultManufacturer" class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ $t("components.template.form.manufacturer") }}</dt>
|
||||
<dd>{{ template.defaultManufacturer }}</dd>
|
||||
</div>
|
||||
<div v-if="template.defaultLocation" class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ $t("components.template.form.location") }}</dt>
|
||||
<dd>{{ template.defaultLocation.name }}</dd>
|
||||
</div>
|
||||
<div v-if="template.defaultLabels && template.defaultLabels.length > 0" class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ $t("global.labels") }}</dt>
|
||||
<dd>{{ template.defaultLabels.map(l => l.name).join(", ") }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ $t("global.insured") }}</dt>
|
||||
<dd>{{ template.defaultInsured ? $t("global.yes") : $t("global.no") }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ $t("components.template.form.lifetime_warranty") }}</dt>
|
||||
<dd>{{ template.defaultLifetimeWarranty ? $t("global.yes") : $t("global.no") }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div v-if="template.fields.length > 0">
|
||||
<h3 class="mb-2 font-medium">{{ $t("components.template.form.custom_fields") }}</h3>
|
||||
<dl class="flex flex-col gap-1">
|
||||
<div v-for="field in template.fields" :key="field.id" class="flex justify-between">
|
||||
<dt class="text-muted-foreground">{{ field.name }}</dt>
|
||||
<dd>{{ field.textValue || "—" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
70
frontend/pages/templates.vue
Normal file
70
frontend/pages/templates.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import MdiPlus from "~icons/mdi/plus";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import TemplateCard from "~/components/Template/Card.vue";
|
||||
import TemplateCreateModal from "~/components/Template/CreateModal.vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
useHead({
|
||||
title: computed(() => `HomeBox | ${t("pages.templates.title")}`),
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const { data: templates, refresh } = useAsyncData("templates", async () => {
|
||||
const { data, error } = await api.templates.getAll();
|
||||
if (error) {
|
||||
toast.error(t("components.template.toast.load_failed"));
|
||||
return [];
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
// Wrapper functions to match event signatures
|
||||
const handleRefresh = () => refresh();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const handleDuplicated = (_id: string) => refresh();
|
||||
</script>
|
||||
<template>
|
||||
<BaseContainer>
|
||||
<div class="mb-4 flex justify-between">
|
||||
<BaseSectionHeader>{{ $t("pages.templates.title") }}</BaseSectionHeader>
|
||||
<Button @click="openDialog(DialogID.CreateTemplate)">
|
||||
<MdiPlus class="mr-2" />
|
||||
{{ $t("global.create") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TemplateCreateModal @created="handleRefresh" />
|
||||
|
||||
<div v-if="templates && templates.length > 0" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<TemplateCard
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
:template="tpl"
|
||||
@deleted="handleRefresh"
|
||||
@duplicated="handleDuplicated"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p class="mb-4 text-muted-foreground">{{ $t("pages.templates.no_templates") }}</p>
|
||||
<Button @click="openDialog(DialogID.CreateTemplate)">
|
||||
<MdiPlus class="mr-2" />
|
||||
{{ $t("components.template.create_modal.title") }}
|
||||
</Button>
|
||||
</div>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -25,5 +26,5 @@ export default defineConfig({
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
globalTeardown: require.resolve("./playwright.teardown"),
|
||||
globalTeardown: fileURLToPath(new URL("./playwright.teardown.ts", import.meta.url)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user