From 434f1fa41135b449ea82ec1b4edd34c66c955f39 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sat, 15 Oct 2022 21:41:27 -0800 Subject: [PATCH] add support for custom text fields --- backend/app/api/docs/docs.go | 49 +++++++++- backend/app/api/docs/swagger.json | 49 +++++++++- backend/app/api/docs/swagger.yaml | 36 ++++++- .../app/api/v1/v1_ctrl_items_attachments.go | 10 +- backend/internal/repo/repo_items.go | 97 ++++++++++++++++++- frontend/components/Form/Select.vue | 38 +++++--- frontend/lib/api/__test__/factories/index.ts | 15 ++- frontend/lib/api/__test__/user/items.test.ts | 57 ++++++++++- frontend/lib/api/types/data-contracts.ts | 14 +++ frontend/pages/item/[id]/edit.vue | 53 ++++++++++ frontend/pages/item/[id]/index.vue | 4 + 11 files changed, 384 insertions(+), 38 deletions(-) diff --git a/backend/app/api/docs/docs.go b/backend/app/api/docs/docs.go index b77cd35..707a3c4 100644 --- a/backend/app/api/docs/docs.go +++ b/backend/app/api/docs/docs.go @@ -352,7 +352,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Items" + "Items Attachments" ], "summary": "imports items into the database", "parameters": [ @@ -415,7 +415,7 @@ const docTemplate = `{ "application/octet-stream" ], "tags": [ - "Items" + "Items Attachments" ], "summary": "retrieves an attachment for an item", "parameters": [ @@ -452,7 +452,7 @@ const docTemplate = `{ "application/octet-stream" ], "tags": [ - "Items" + "Items Attachments" ], "summary": "retrieves an attachment for an item", "parameters": [ @@ -487,7 +487,7 @@ const docTemplate = `{ } ], "tags": [ - "Items" + "Items Attachments" ], "summary": "retrieves an attachment for an item", "parameters": [ @@ -531,7 +531,7 @@ const docTemplate = `{ } ], "tags": [ - "Items" + "Items Attachments" ], "summary": "retrieves an attachment for an item", "parameters": [ @@ -1256,6 +1256,32 @@ const docTemplate = `{ } } }, + "repo.ItemField": { + "type": "object", + "properties": { + "booleanValue": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "numberValue": { + "type": "integer" + }, + "textValue": { + "type": "string" + }, + "timeValue": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "repo.ItemOut": { "type": "object", "properties": { @@ -1271,6 +1297,13 @@ const docTemplate = `{ "description": { "type": "string" }, + "fields": { + "description": "Future", + "type": "array", + "items": { + "$ref": "#/definitions/repo.ItemField" + } + }, "id": { "type": "string" }, @@ -1388,6 +1421,12 @@ const docTemplate = `{ "description": { "type": "string" }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.ItemField" + } + }, "id": { "type": "string" }, diff --git a/backend/app/api/docs/swagger.json b/backend/app/api/docs/swagger.json index 913b6a9..bfa404d 100644 --- a/backend/app/api/docs/swagger.json +++ b/backend/app/api/docs/swagger.json @@ -344,7 +344,7 @@ "application/json" ], "tags": [ - "Items" + "Items Attachments" ], "summary": "imports items into the database", "parameters": [ @@ -407,7 +407,7 @@ "application/octet-stream" ], "tags": [ - "Items" + "Items Attachments" ], "summary": "retrieves an attachment for an item", "parameters": [ @@ -444,7 +444,7 @@ "application/octet-stream" ], "tags": [ - "Items" + "Items Attachments" ], "summary": "retrieves an attachment for an item", "parameters": [ @@ -479,7 +479,7 @@ } ], "tags": [ - "Items" + "Items Attachments" ], "summary": "retrieves an attachment for an item", "parameters": [ @@ -523,7 +523,7 @@ } ], "tags": [ - "Items" + "Items Attachments" ], "summary": "retrieves an attachment for an item", "parameters": [ @@ -1248,6 +1248,32 @@ } } }, + "repo.ItemField": { + "type": "object", + "properties": { + "booleanValue": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "numberValue": { + "type": "integer" + }, + "textValue": { + "type": "string" + }, + "timeValue": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "repo.ItemOut": { "type": "object", "properties": { @@ -1263,6 +1289,13 @@ "description": { "type": "string" }, + "fields": { + "description": "Future", + "type": "array", + "items": { + "$ref": "#/definitions/repo.ItemField" + } + }, "id": { "type": "string" }, @@ -1380,6 +1413,12 @@ "description": { "type": "string" }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.ItemField" + } + }, "id": { "type": "string" }, diff --git a/backend/app/api/docs/swagger.yaml b/backend/app/api/docs/swagger.yaml index aca9f01..8ed1389 100644 --- a/backend/app/api/docs/swagger.yaml +++ b/backend/app/api/docs/swagger.yaml @@ -63,6 +63,23 @@ definitions: name: type: string type: object + repo.ItemField: + properties: + booleanValue: + type: boolean + id: + type: string + name: + type: string + numberValue: + type: integer + textValue: + type: string + timeValue: + type: string + type: + type: string + type: object repo.ItemOut: properties: attachments: @@ -73,6 +90,11 @@ definitions: type: string description: type: string + fields: + description: Future + items: + $ref: '#/definitions/repo.ItemField' + type: array id: type: string insured: @@ -153,6 +175,10 @@ definitions: properties: description: type: string + fields: + items: + $ref: '#/definitions/repo.ItemField' + type: array id: type: string insured: @@ -653,7 +679,7 @@ paths: - Bearer: [] summary: imports items into the database tags: - - Items + - Items Attachments /v1/items/{id}/attachments/{attachment_id}: delete: parameters: @@ -674,7 +700,7 @@ paths: - Bearer: [] summary: retrieves an attachment for an item tags: - - Items + - Items Attachments get: parameters: - description: Item ID @@ -698,7 +724,7 @@ paths: - Bearer: [] summary: retrieves an attachment for an item tags: - - Items + - Items Attachments put: parameters: - description: Item ID @@ -726,7 +752,7 @@ paths: - Bearer: [] summary: retrieves an attachment for an item tags: - - Items + - Items Attachments /v1/items/{id}/attachments/download: get: parameters: @@ -749,7 +775,7 @@ paths: - Bearer: [] summary: retrieves an attachment for an item tags: - - Items + - Items Attachments /v1/items/import: post: parameters: diff --git a/backend/app/api/v1/v1_ctrl_items_attachments.go b/backend/app/api/v1/v1_ctrl_items_attachments.go index a32aaab..d246eb0 100644 --- a/backend/app/api/v1/v1_ctrl_items_attachments.go +++ b/backend/app/api/v1/v1_ctrl_items_attachments.go @@ -22,7 +22,7 @@ type ( // HandleItemsImport godocs // @Summary imports items into the database -// @Tags Items +// @Tags Items Attachments // @Produce json // @Param id path string true "Item ID" // @Param file formData file true "File attachment" @@ -99,7 +99,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { // HandleItemAttachmentGet godocs // @Summary retrieves an attachment for an item -// @Tags Items +// @Tags Items Attachments // @Produce application/octet-stream // @Param id path string true "Item ID" // @Param token query string true "Attachment token" @@ -126,7 +126,7 @@ func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc { // HandleItemAttachmentToken godocs // @Summary retrieves an attachment for an item -// @Tags Items +// @Tags Items Attachments // @Produce application/octet-stream // @Param id path string true "Item ID" // @Param attachment_id path string true "Attachment ID" @@ -139,7 +139,7 @@ func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc { // HandleItemAttachmentDelete godocs // @Summary retrieves an attachment for an item -// @Tags Items +// @Tags Items Attachments // @Param id path string true "Item ID" // @Param attachment_id path string true "Attachment ID" // @Success 204 @@ -151,7 +151,7 @@ func (ctrl *V1Controller) HandleItemAttachmentDelete() http.HandlerFunc { // HandleItemAttachmentUpdate godocs // @Summary retrieves an attachment for an item -// @Tags Items +// @Tags Items Attachments // @Param id path string true "Item ID" // @Param attachment_id path string true "Attachment ID" // @Param payload body repo.ItemAttachmentUpdate true "Attachment Update" diff --git a/backend/internal/repo/repo_items.go b/backend/internal/repo/repo_items.go index a0aa6f9..c7ca5e6 100644 --- a/backend/internal/repo/repo_items.go +++ b/backend/internal/repo/repo_items.go @@ -8,6 +8,7 @@ import ( "github.com/hay-kot/homebox/backend/ent" "github.com/hay-kot/homebox/backend/ent/group" "github.com/hay-kot/homebox/backend/ent/item" + "github.com/hay-kot/homebox/backend/ent/itemfield" "github.com/hay-kot/homebox/backend/ent/label" "github.com/hay-kot/homebox/backend/ent/location" "github.com/hay-kot/homebox/backend/ent/predicate" @@ -27,6 +28,16 @@ type ( SortBy string `json:"sortBy"` } + ItemField struct { + ID uuid.UUID `json:"id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + TextValue string `json:"textValue"` + NumberValue int `json:"numberValue"` + BooleanValue bool `json:"booleanValue"` + TimeValue time.Time `json:"timeValue,omitempty"` + } + ItemCreate struct { ImportRef string `json:"-"` Name string `json:"name"` @@ -69,8 +80,8 @@ type ( SoldNotes string `json:"soldNotes"` // Extras - Notes string `json:"notes"` - // Fields []*FieldSummary `json:"fields"` + Notes string `json:"notes"` + Fields []ItemField `json:"fields"` } ItemSummary struct { @@ -116,7 +127,7 @@ type ( Attachments []ItemAttachment `json:"attachments"` // Future - // Fields []*FieldSummary `json:"fields"` + Fields []ItemField `json:"fields"` } ) @@ -156,12 +167,33 @@ var ( mapItemOutErr = mapTErrFunc(mapItemOut) ) +func mapFields(fields []*ent.ItemField) []ItemField { + result := make([]ItemField, len(fields)) + for i, f := range fields { + result[i] = ItemField{ + ID: f.ID, + Type: f.Type.String(), + Name: f.Name, + TextValue: f.TextValue, + NumberValue: f.NumberValue, + BooleanValue: f.BooleanValue, + TimeValue: f.TimeValue, + } + } + return result +} + func mapItemOut(item *ent.Item) ItemOut { var attachments []ItemAttachment if item.Edges.Attachments != nil { attachments = mapEach(item.Edges.Attachments, ToItemAttachment) } + var fields []ItemField + if item.Edges.Fields != nil { + fields = mapFields(item.Edges.Fields) + } + return ItemOut{ ItemSummary: mapItemSummary(item), LifetimeWarranty: item.LifetimeWarranty, @@ -187,6 +219,7 @@ func mapItemOut(item *ent.Item) ItemOut { // Extras Notes: item.Notes, Attachments: attachments, + Fields: fields, } } @@ -370,5 +403,63 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, gid uuid.UUID, data return ItemOut{}, err } + fields, err := e.db.ItemField.Query().Where(itemfield.HasItemWith(item.ID(data.ID))).All(ctx) + if err != nil { + return ItemOut{}, err + } + + fieldIds := newIDSet(fields) + + // Update Existing Fields + for _, f := range data.Fields { + if f.ID == uuid.Nil { + // Create New Field + _, err = e.db.ItemField.Create(). + SetItemID(data.ID). + SetType(itemfield.Type(f.Type)). + SetName(f.Name). + SetTextValue(f.TextValue). + SetNumberValue(f.NumberValue). + SetBooleanValue(f.BooleanValue). + SetTimeValue(f.TimeValue). + Save(ctx) + if err != nil { + return ItemOut{}, err + } + } + + opt := e.db.ItemField.Update(). + Where( + itemfield.ID(f.ID), + itemfield.HasItemWith(item.ID(data.ID)), + ). + SetType(itemfield.Type(f.Type)). + SetName(f.Name). + SetTextValue(f.TextValue). + SetNumberValue(f.NumberValue). + SetBooleanValue(f.BooleanValue). + SetTimeValue(f.TimeValue) + + _, err = opt.Save(ctx) + if err != nil { + return ItemOut{}, err + } + + fieldIds.Remove(f.ID) + continue + } + + // Delete Fields that are no longer present + if fieldIds.Len() > 0 { + _, err = e.db.ItemField.Delete(). + Where( + itemfield.IDIn(fieldIds.Slice()...), + itemfield.HasItemWith(item.ID(data.ID)), + ).Exec(ctx) + if err != nil { + return ItemOut{}, err + } + } + return e.GetOne(ctx, data.ID) } diff --git a/frontend/components/Form/Select.vue b/frontend/components/Form/Select.vue index f9d9128..e3616e5 100644 --- a/frontend/components/Form/Select.vue +++ b/frontend/components/Form/Select.vue @@ -50,17 +50,40 @@ const selectedIdx = ref(-1); const internalSelected = useVModel(props, "modelValue", emit); + const internalValue = useVModel(props, "value", emit); watch(selectedIdx, newVal => { internalSelected.value = props.items[newVal]; }); - watch(internalSelected, newVal => { + watch(selectedIdx, newVal => { if (props.valueKey) { - emit("update:value", newVal[props.valueKey]); + internalValue.value = props.items[newVal][props.valueKey]; } }); + watch( + internalSelected, + () => { + const idx = props.items.findIndex(item => compare(item, internalSelected.value)); + selectedIdx.value = idx; + }, + { + immediate: true, + } + ); + + watch( + internalValue, + () => { + const idx = props.items.findIndex(item => compare(item[props.valueKey], internalValue.value)); + selectedIdx.value = idx; + }, + { + immediate: true, + } + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any function compare(a: any, b: any): boolean { if (a === b) { @@ -73,15 +96,4 @@ return JSON.stringify(a) === JSON.stringify(b); } - - watch( - internalSelected, - () => { - const idx = props.items.findIndex(item => compare(item, internalSelected.value)); - selectedIdx.value = idx; - }, - { - immediate: true, - } - ); diff --git a/frontend/lib/api/__test__/factories/index.ts b/frontend/lib/api/__test__/factories/index.ts index 51a0fb0..cebfb5f 100644 --- a/frontend/lib/api/__test__/factories/index.ts +++ b/frontend/lib/api/__test__/factories/index.ts @@ -2,11 +2,23 @@ import { faker } from "@faker-js/faker"; import { expect } from "vitest"; import { overrideParts } from "../../base/urls"; import { PublicApi } from "../../public"; -import { LabelCreate, LocationCreate, UserRegistration } from "../../types/data-contracts"; +import { ItemField, LabelCreate, LocationCreate, UserRegistration } from "../../types/data-contracts"; import * as config from "../../../../test/config"; import { UserClient } from "../../user"; import { Requests } from "../../../requests"; +function itemField(id = null): ItemField { + return { + id, + name: faker.lorem.word(), + type: "text", + textValue: faker.lorem.sentence(), + booleanValue: false, + numberValue: faker.datatype.number(), + timeValue: null, + }; +} + /** * Returns a random user registration object that can be * used to signup a new user. @@ -72,6 +84,7 @@ export const factories = { user, location, label, + itemField, client: { public: publicClient, user: userClient, diff --git a/frontend/lib/api/__test__/user/items.test.ts b/frontend/lib/api/__test__/user/items.test.ts index 9bdea4d..7837e50 100644 --- a/frontend/lib/api/__test__/user/items.test.ts +++ b/frontend/lib/api/__test__/user/items.test.ts @@ -1,7 +1,9 @@ +import { faker } from "@faker-js/faker"; import { describe, test, expect } from "vitest"; -import { LocationOut } from "../../types/data-contracts"; +import { ItemField, LocationOut } from "../../types/data-contracts"; import { AttachmentTypes } from "../../types/non-generated"; import { UserClient } from "../../user"; +import { factories } from "../factories"; import { sharedUserClient } from "../test-utils"; describe("user should be able to create an item and add an attachment", () => { @@ -58,4 +60,57 @@ describe("user should be able to create an item and add an attachment", () => { api.items.delete(item.id); await cleanup(); }); + + test("user should be able to create and delete fields on an item", async () => { + const api = await sharedUserClient(); + const [location, cleanup] = await useLocation(api); + + const { response, data: item } = await api.items.create({ + name: faker.vehicle.model(), + labelIds: [], + description: faker.lorem.paragraph(1), + locationId: location.id, + }); + expect(response.status).toBe(201); + + const fields: ItemField[] = [ + factories.itemField(), + factories.itemField(), + factories.itemField(), + factories.itemField(), + ]; + + // Add fields + const itemUpdate = { + ...item, + locationId: item.location.id, + labelIds: item.labels.map(l => l.id), + fields, + }; + + const { response: updateResponse, data: item2 } = await api.items.update(item.id, itemUpdate); + expect(updateResponse.status).toBe(200); + + expect(item2.fields).toHaveLength(fields.length); + + for (let i = 0; i < fields.length; i++) { + expect(item2.fields[i].name).toBe(fields[i].name); + expect(item2.fields[i].textValue).toBe(fields[i].textValue); + expect(item2.fields[i].numberValue).toBe(fields[i].numberValue); + } + + itemUpdate.fields = [fields[0], fields[1]]; + + const { response: updateResponse2, data: item3 } = await api.items.update(item.id, itemUpdate); + expect(updateResponse2.status).toBe(200); + + expect(item3.fields).toHaveLength(2); + for (let i = 0; i < item3.fields.length; i++) { + expect(item3.fields[i].name).toBe(itemUpdate.fields[i].name); + expect(item3.fields[i].textValue).toBe(itemUpdate.fields[i].textValue); + expect(item3.fields[i].numberValue).toBe(itemUpdate.fields[i].numberValue); + } + + cleanup(); + }); }); diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 360ae2a..41c9610 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -51,10 +51,23 @@ export interface ItemCreate { name: string; } +export interface ItemField { + booleanValue: boolean; + id: string; + name: string; + numberValue: number; + textValue: string; + timeValue: string; + type: string; +} + export interface ItemOut { attachments: ItemAttachment[]; createdAt: Date; description: string; + + /** Future */ + fields: ItemField[]; id: string; insured: boolean; labels: LabelSummary[]; @@ -108,6 +121,7 @@ export interface ItemSummary { export interface ItemUpdate { description: string; + fields: ItemField[]; id: string; insured: boolean; labelIds: string[]; diff --git a/frontend/pages/item/[id]/edit.vue b/frontend/pages/item/[id]/edit.vue index c5cfb53..b08b42c 100644 --- a/frontend/pages/item/[id]/edit.vue +++ b/frontend/pages/item/[id]/edit.vue @@ -278,6 +278,34 @@ toast.success("Attachment updated"); } + + // Custom Fields + // const fieldTypes = [ + // { + // name: "Text", + // value: "text", + // }, + // { + // name: "Number", + // value: "number", + // }, + // { + // name: "Boolean", + // value: "boolean", + // }, + // ]; + + function addField() { + item.value.fields.push({ + id: null, + name: "Field Name", + type: "text", + textValue: "", + numberValue: 0, + booleanValue: false, + timeValue: null, + }); + }