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

@ -25,7 +25,7 @@
},
modelValue: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Object as any,
type: [Object, String, Boolean] as any,
default: null,
},
items: {
@ -37,18 +37,47 @@
type: String,
default: "name",
},
value: {
type: String,
default: null,
required: false,
},
selectFirst: {
type: Boolean,
default: false,
},
});
watchOnce(
() => props.items,
() => {
if (props.selectFirst && props.items.length > 0) {
function syncSelect() {
if (!props.modelValue) {
if (props.selectFirst) {
selectedIdx.value = 0;
}
return;
}
// Check if we're already synced
if (props.value) {
if (props.modelValue[props.value] === props.items[selectedIdx.value][props.value]) {
return;
}
} else if (props.modelValue === props.items[selectedIdx.value]) {
return;
}
const idx = props.items.findIndex(item => {
if (props.value) {
return item[props.value] === props.modelValue;
}
return item === props.modelValue;
});
selectedIdx.value = idx;
}
watch(
() => props.modelValue,
() => {
syncSelect();
}
);
@ -56,6 +85,10 @@
watch(
() => selectedIdx.value,
() => {
if (props.value) {
emit("update:modelValue", props.items[selectedIdx.value][props.value]);
return;
}
emit("update:modelValue", props.items[selectedIdx.value]);
}
);

View file

@ -0,0 +1,56 @@
<template>
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
<li
v-for="attachment in attachments"
:key="attachment.id"
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"> {{ attachment.document.title }}</span>
</div>
<div class="ml-4 flex-shrink-0">
<button class="font-medium" @click="getAttachmentUrl(attachment)">Download</button>
</div>
</li>
</ul>
</template>
<script setup lang="ts">
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
const props = defineProps({
attachments: {
type: Object as () => ItemAttachment[],
required: true,
},
itemId: {
type: String,
required: true,
},
});
const api = useUserApi();
const toast = useNotifier();
async function getAttachmentUrl(attachment: ItemAttachment) {
const url = await api.items.getAttachmentUrl(props.itemId, attachment.id);
if (!url) {
toast.error("Failed to get attachment url");
return;
}
if (!document) {
window.open(url, "_blank");
return;
}
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.setAttribute("download", attachment.document.title);
link.click();
}
</script>
<style scoped></style>

View file

@ -23,6 +23,7 @@
<script setup lang="ts">
import { ItemOut, ItemSummary } from "~~/lib/api/types/data-contracts";
import { truncate } from "~~/lib/strings";
const props = defineProps({
item: {

View file

@ -0,0 +1,31 @@
<template>
<div
ref="el"
class="h-24 w-full border-2 border-primary border-dashed grid place-content-center"
:class="isOverDropZone ? 'bg-primary bg-opacity-10' : ''"
>
<slot />
</div>
</template>
<script setup lang="ts">
defineProps({
modelValue: {
type: Boolean,
required: false,
},
});
const emit = defineEmits(["update:modelValue", "drop"]);
const el = ref<HTMLDivElement>(null);
const { isOverDropZone } = useDropZone(el, files => {
emit("drop", files);
});
watch(isOverDropZone, () => {
emit("update:modelValue", isOverDropZone.value);
});
</script>
<style scoped></style>

View file

@ -1,7 +0,0 @@
export function truncate(str: string, length: number) {
return str.length > length ? str.substring(0, length) + "..." : str;
}
export function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

View file

@ -0,0 +1,61 @@
import { describe, test, expect } from "vitest";
import { LocationOut } from "../../types/data-contracts";
import { AttachmentTypes } from "../../types/non-generated";
import { UserApi } from "../../user";
import { sharedUserClient } from "../test-utils";
describe("user should be able to create an item and add an attachment", () => {
let increment = 0;
/**
* useLocatio sets up a location resource for testing, and returns a function
* that can be used to delete the location from the backend server.
*/
async function useLocation(api: UserApi): Promise<[LocationOut, () => Promise<void>]> {
const { response, data } = await api.locations.create({
name: `__test__.location.name_${increment}`,
description: `__test__.location.description_${increment}`,
});
expect(response.status).toBe(201);
increment++;
const cleanup = async () => {
const { response } = await api.locations.delete(data.id);
expect(response.status).toBe(204);
};
return [data, cleanup];
}
test("user should be able to create an item and add an attachment", async () => {
const api = await sharedUserClient();
const [location, cleanup] = await useLocation(api);
const { response, data: item } = await api.items.create({
name: "test-item",
labelIds: [],
description: "test-description",
locationId: location.id,
});
expect(response.status).toBe(201);
// Add attachment
{
const testFile = new Blob(["test"], { type: "text/plain" });
const { response } = await api.items.addAttachment(item.id, testFile, "test.txt", AttachmentTypes.Attachment);
expect(response.status).toBe(201);
}
// Get Attachment
const { response: itmResp, data } = await api.items.get(item.id);
expect(itmResp.status).toBe(200);
expect(data.attachments).toHaveLength(1);
expect(data.attachments[0].document.title).toBe("test.txt");
const resp = await api.items.deleteAttachment(data.id, data.attachments[0].id);
expect(resp.response.status).toBe(204);
api.items.delete(item.id);
await cleanup();
});
});

View file

@ -1,15 +1,23 @@
import { BaseAPI, route } from "../base";
import { parseDate } from "../base/base-api";
import { ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts";
import {
ItemAttachmentToken,
ItemAttachmentUpdate,
ItemCreate,
ItemOut,
ItemSummary,
ItemUpdate,
} from "../types/data-contracts";
import { AttachmentTypes } from "../types/non-generated";
import { Results } from "./types";
export class ItemsApi extends BaseAPI {
getAll() {
return this.http.get<Results<ItemOut>>({ url: route("/items") });
return this.http.get<Results<ItemSummary>>({ url: route("/items") });
}
create(item: ItemCreate) {
return this.http.post<ItemCreate, ItemSummary>({ url: route("/items"), body: item });
return this.http.post<ItemCreate, ItemOut>({ url: route("/items"), body: item });
}
async get(id: string) {
@ -45,6 +53,44 @@ export class ItemsApi extends BaseAPI {
const formData = new FormData();
formData.append("csv", file);
return this.http.post<FormData, void>({ url: route("/items/import"), data: formData });
return this.http.post<FormData, void>({
url: route("/items/import"),
data: formData,
});
}
addAttachment(id: string, file: File | Blob, filename: string, type: AttachmentTypes) {
const formData = new FormData();
formData.append("file", file);
formData.append("type", type);
formData.append("name", filename);
return this.http.post<FormData, ItemOut>({
url: route(`/items/${id}/attachments`),
data: formData,
});
}
async getAttachmentUrl(id: string, attachmentId: string): Promise<string> {
const payload = await this.http.get<ItemAttachmentToken>({
url: route(`/items/${id}/attachments/${attachmentId}`),
});
if (!payload.data) {
return "";
}
return route(`/items/${id}/attachments/download`, { token: payload.data.token });
}
async deleteAttachment(id: string, attachmentId: string) {
return await this.http.delete<void>({ url: route(`/items/${id}/attachments/${attachmentId}`) });
}
async updateAttachment(id: string, attachmentId: string, data: ItemAttachmentUpdate) {
return await this.http.put<ItemAttachmentUpdate, ItemOut>({
url: route(`/items/${id}/attachments/${attachmentId}`),
body: data,
});
}
}

View file

@ -21,6 +21,11 @@ export interface ServerResults {
items: any;
}
export interface ServerValidationError {
field: string;
reason: string;
}
export interface ApiSummary {
build: Build;
health: boolean;
@ -45,9 +50,19 @@ export interface ItemAttachment {
createdAt: Date;
document: DocumentOut;
id: string;
type: string;
updatedAt: Date;
}
export interface ItemAttachmentToken {
token: string;
}
export interface ItemAttachmentUpdate {
title: string;
type: string;
}
export interface ItemCreate {
description: string;
labelIds: string[];
@ -191,7 +206,6 @@ export interface LabelCreate {
export interface LabelOut {
createdAt: Date;
description: string;
groupId: string;
id: string;
items: ItemSummary[];
name: string;
@ -201,7 +215,6 @@ export interface LabelOut {
export interface LabelSummary {
createdAt: Date;
description: string;
groupId: string;
id: string;
name: string;
updatedAt: Date;

View file

@ -0,0 +1,6 @@
export enum AttachmentTypes {
Photo = "photo",
Manual = "manual",
Warranty = "warranty",
Attachment = "attachment",
}

View file

@ -97,11 +97,15 @@ export class Requests {
return {} as T;
}
try {
return await response.json();
} catch (e) {
return {} as T;
if (response.headers.get("Content-Type")?.startsWith("application/json")) {
try {
return await response.json();
} catch (e) {
return {} as T;
}
}
return response.body as unknown as T;
})();
return {

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { titlecase, capitalize, truncate } from ".";
describe("title case tests", () => {
it("should return the same string if it's already title case", () => {
expect(titlecase("Hello World")).toBe("Hello World");
});
it("should title case a lower case word", () => {
expect(titlecase("hello")).toBe("Hello");
});
it("should title case a sentence", () => {
expect(titlecase("hello world")).toBe("Hello World");
});
it("should title case a sentence with multiple words", () => {
expect(titlecase("hello world this is a test")).toBe("Hello World This Is A Test");
});
});
describe("capitilize tests", () => {
it("should return the same string if it's already capitalized", () => {
expect(capitalize("Hello")).toBe("Hello");
});
it("should capitalize a lower case word", () => {
expect(capitalize("hello")).toBe("Hello");
});
it("should capitalize a sentence", () => {
expect(capitalize("hello world")).toBe("Hello world");
});
it("should capitalize a sentence with multiple words", () => {
expect(capitalize("hello world this is a test")).toBe("Hello world this is a test");
});
});
describe("truncase tests", () => {
it("should return the same string if it's already truncated", () => {
expect(truncate("Hello", 5)).toBe("Hello");
});
it("should truncate a lower case word", () => {
expect(truncate("hello", 3)).toBe("hel...");
});
it("should truncate a sentence", () => {
expect(truncate("hello world", 5)).toBe("hello...");
});
it("should truncate a sentence with multiple words", () => {
expect(truncate("hello world this is a test", 10)).toBe("hello worl...");
});
});

View file

@ -0,0 +1,14 @@
export function titlecase(str: string) {
return str
.split(" ")
.map(word => word[0].toUpperCase() + word.slice(1))
.join(" ");
}
export function capitalize(str: string) {
return str[0].toUpperCase() + str.slice(1);
}
export function truncate(str: string, length: number) {
return str.length > length ? str.substring(0, length) + "..." : str;
}

View file

@ -121,7 +121,7 @@
<div class="sm:flex sm:space-x-5">
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
<p class="text-sm font-medium text-gray-600">Welcome back,</p>
<p class="text-xl font-bold text-gray-900 sm:text-2xl">Hayden Kotelman</p>
<p class="text-xl font-bold text-gray-900 sm:text-2xl">Username</p>
<p class="text-sm font-medium text-gray-600">User</p>
</div>
</div>

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