feat: item-attachments CRUD (#22)

* change /content/ -> /homebox/

* add cache to code generators

* update env variables to set data storage

* update env variables

* set env variables in prod container

* implement attachment post route (WIP)

* get attachment endpoint

* attachment download

* implement string utilities lib

* implement generic drop zone

* use explicit truncate

* remove clean dir

* drop strings composable for lib

* update item types and add attachments

* add attachment API

* implement service context

* consolidate API code

* implement editing attachments

* implement upload limit configuration

* improve error handling

* add docs for max upload size

* fix test cases
This commit is contained in:
Hayden 2022-09-24 11:33:38 -08:00 committed by GitHub
parent 852d312ba7
commit 31b34241e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
165 changed files with 2509 additions and 664 deletions

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import { ItemUpdate } from "~~/lib/api/types/data-contracts";
import { ItemAttachment, ItemUpdate } from "~~/lib/api/types/data-contracts";
import { AttachmentTypes } from "~~/lib/api/types/non-generated";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
import { capitalize } from "~~/lib/strings";
definePageMeta({
layout: "home",
@ -154,10 +156,131 @@
ref: "soldTime",
},
];
// - Attachments
const attDropZone = ref<HTMLDivElement>();
const { isOverDropZone: attDropZoneActive } = useDropZone(attDropZone);
const refAttachmentInput = ref<HTMLInputElement>();
function clickUpload() {
if (!refAttachmentInput.value) {
return;
}
refAttachmentInput.value.click();
}
function uploadImage(e: InputEvent) {
const files = (e.target as HTMLInputElement).files;
if (!files) {
return;
}
uploadAttachment([files.item(0)], AttachmentTypes.Attachment);
}
const dropPhoto = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Photo);
const dropAttachment = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Attachment);
const dropWarranty = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Warranty);
const dropManual = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Manual);
async function uploadAttachment(files: File[] | null, type: AttachmentTypes) {
if (!files && files.length === 0) {
return;
}
const { data, error } = await api.items.addAttachment(itemId.value, files[0], files[0].name, type);
if (error) {
toast.error("Failed to upload attachment");
return;
}
toast.success("Attachment uploaded");
item.value.attachments = data.attachments;
}
const confirm = useConfirm();
async function deleteAttachment(attachmentId: string) {
const confirmed = await confirm.reveal("Are you sure you want to delete this attachment?");
if (confirmed.isCanceled) {
return;
}
const { error } = await api.items.deleteAttachment(itemId.value, attachmentId);
if (error) {
toast.error("Failed to delete attachment");
return;
}
toast.success("Attachment deleted");
item.value.attachments = item.value.attachments.filter(a => a.id !== attachmentId);
}
const editState = reactive({
modal: false,
loading: false,
// Values
id: "",
title: "",
type: "",
});
const attachmentOpts = Object.entries(AttachmentTypes).map(([key, value]) => ({
text: capitalize(key),
value,
}));
function openAttachmentEditDialog(attachment: ItemAttachment) {
editState.id = attachment.id;
editState.title = attachment.document.title;
editState.type = attachment.type;
editState.modal = true;
}
async function updateAttachment() {
editState.loading = true;
const { error, data } = await api.items.updateAttachment(itemId.value, editState.id, {
title: editState.title,
type: editState.type,
});
if (error) {
toast.error("Failed to update attachment");
return;
}
item.value.attachments = data.attachments;
editState.loading = false;
editState.modal = false;
editState.id = "";
editState.title = "";
editState.type = "";
toast.success("Attachment updated");
}
</script>
<template>
<BaseContainer v-if="item" class="pb-8">
<BaseModal v-model="editState.modal">
<template #title> Attachment Edit </template>
<FormTextField v-model="editState.title" label="Attachment Title" />
<FormSelect v-model="editState.type" label="Attachment Type" value="value" name="text" :items="attachmentOpts" />
<div class="modal-action">
<BaseButton :loading="editState.loading" @click="updateAttachment"> Update </BaseButton>
</div>
</BaseModal>
<section class="px-3">
<div class="space-y-4">
<div class="overflow-hidden card bg-base-100 shadow-xl sm:rounded-lg">
@ -223,6 +346,62 @@
</div>
</div>
<div
v-if="!preferences.editorSimpleView"
ref="attDropZone"
class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg"
>
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">Attachments</h3>
<p class="text-xs">Changes to attachments will be saved immediately</p>
</div>
<div class="border-t border-gray-300 p-4">
<div v-if="attDropZoneActive" class="grid grid-cols-4 gap-4">
<DropZone @drop="dropPhoto"> Photo </DropZone>
<DropZone @drop="dropWarranty"> Warranty </DropZone>
<DropZone @drop="dropManual"> Manual </DropZone>
<DropZone @drop="dropAttachment"> Attachment </DropZone>
</div>
<button
v-else
class="h-24 w-full border-2 border-primary border-dashed grid place-content-center"
@click="clickUpload"
>
<input ref="refAttachmentInput" hidden type="file" @change="uploadImage" />
<p>Drag and drop files here or click to select files</p>
</button>
</div>
<div class="border-t border-gray-300 p-4">
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
<li
v-for="attachment in item.attachments"
:key="attachment.id"
class="grid grid-cols-6 justify-between py-3 pl-3 pr-4 text-sm"
>
<p class="my-auto col-span-4">
{{ attachment.document.title }}
</p>
<p class="my-auto">
{{ capitalize(attachment.type) }}
</p>
<div class="flex gap-2 justify-end">
<div class="tooltip" data-tip="Delete">
<button class="btn btn-sm btn-square" @click="deleteAttachment(attachment.id)">
<Icon name="mdi-delete" />
</button>
</div>
<div class="tooltip" data-tip="Edit">
<button class="btn btn-sm btn-square" @click="openAttachmentEditDialog(attachment)">
<Icon name="mdi-pencil" />
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
<div v-if="!preferences.editorSimpleView" class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">Purchase Details</h3>

View file

@ -1,4 +1,6 @@
<script setup lang="ts">
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
definePageMeta({
layout: "home",
});
@ -23,6 +25,45 @@
refresh();
});
type FilteredAttachments = {
photos: ItemAttachment[];
attachments: ItemAttachment[];
warranty: ItemAttachment[];
manuals: ItemAttachment[];
};
const attachments = computed<FilteredAttachments>(() => {
if (!item.value) {
return {
photos: [],
attachments: [],
manuals: [],
warranty: [],
};
}
return item.value.attachments.reduce(
(acc, attachment) => {
if (attachment.type === "photo") {
acc.photos.push(attachment);
} else if (attachment.type === "warranty") {
acc.warranty.push(attachment);
} else if (attachment.type === "manual") {
acc.manuals.push(attachment);
} else {
acc.attachments.push(attachment);
}
return acc;
},
{
photos: [] as ItemAttachment[],
attachments: [] as ItemAttachment[],
warranty: [] as ItemAttachment[],
manuals: [] as ItemAttachment[],
}
);
});
const itemSummary = computed(() => {
return {
Description: item.value?.description || "",
@ -31,10 +72,53 @@
Manufacturer: item.value?.manufacturer || "",
Notes: item.value?.notes || "",
Insured: item.value?.insured ? "Yes" : "No",
Attachments: "", // TODO: Attachments
};
});
const showAttachments = computed(() => {
if (preferences.value?.showEmpty) {
return true;
}
return (
attachments.value.photos.length > 0 ||
attachments.value.attachments.length > 0 ||
attachments.value.warranty.length > 0 ||
attachments.value.manuals.length > 0
);
});
const itemAttachments = computed(() => {
const val: Record<string, string> = {};
if (preferences.value.showEmpty) {
return {
Photos: "",
Manuals: "",
Warranty: "",
Attachments: "",
};
}
if (attachments.value.photos.length > 0) {
val.Photos = "";
}
if (attachments.value.manuals.length > 0) {
val.Manuals = "";
}
if (attachments.value.warranty.length > 0) {
val.Warranty = "";
}
if (attachments.value.attachments.length > 0) {
val.Attachments = "";
}
return val;
});
const showWarranty = computed(() => {
if (preferences.value.showEmpty) {
return true;
@ -148,27 +232,36 @@
</template>
</BaseSectionHeader>
</template>
</BaseDetails>
<BaseDetails v-if="showAttachments" :details="itemAttachments">
<template #title> Attachments </template>
<template #Manuals>
<ItemAttachmentsList
v-if="attachments.manuals.length > 0"
:attachments="attachments.manuals"
:item-id="item.id"
/>
</template>
<template #Attachments>
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
<li class="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div class="flex w-0 flex-1 items-center">
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
<span class="ml-2 w-0 flex-1 truncate">User Guide.pdf</span>
</div>
<div class="ml-4 flex-shrink-0">
<a href="#" class="font-medium">Download</a>
</div>
</li>
<li class="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div class="flex w-0 flex-1 items-center">
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
<span class="ml-2 w-0 flex-1 truncate">Purchase Receipts.pdf</span>
</div>
<div class="ml-4 flex-shrink-0">
<a href="#" class="font-medium">Download</a>
</div>
</li>
</ul>
<ItemAttachmentsList
v-if="attachments.attachments.length > 0"
:attachments="attachments.attachments"
:item-id="item.id"
/>
</template>
<template #Warranty>
<ItemAttachmentsList
v-if="attachments.warranty.length > 0"
:attachments="attachments.warranty"
:item-id="item.id"
/>
</template>
<template #Photos>
<ItemAttachmentsList
v-if="attachments.photos.length > 0"
:attachments="attachments.photos"
:item-id="item.id"
/>
</template>
</BaseDetails>
<BaseDetails v-if="showPurchase" :details="purchaseDetails">