add support for custom text fields

This commit is contained in:
Hayden 2022-10-15 21:41:27 -08:00
parent 57f9372e49
commit 434f1fa411
11 changed files with 384 additions and 38 deletions

View file

@ -352,7 +352,7 @@ const docTemplate = `{
"application/json" "application/json"
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "imports items into the database", "summary": "imports items into the database",
"parameters": [ "parameters": [
@ -415,7 +415,7 @@ const docTemplate = `{
"application/octet-stream" "application/octet-stream"
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "retrieves an attachment for an item", "summary": "retrieves an attachment for an item",
"parameters": [ "parameters": [
@ -452,7 +452,7 @@ const docTemplate = `{
"application/octet-stream" "application/octet-stream"
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "retrieves an attachment for an item", "summary": "retrieves an attachment for an item",
"parameters": [ "parameters": [
@ -487,7 +487,7 @@ const docTemplate = `{
} }
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "retrieves an attachment for an item", "summary": "retrieves an attachment for an item",
"parameters": [ "parameters": [
@ -531,7 +531,7 @@ const docTemplate = `{
} }
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "retrieves an attachment for an item", "summary": "retrieves an attachment for an item",
"parameters": [ "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": { "repo.ItemOut": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -1271,6 +1297,13 @@ const docTemplate = `{
"description": { "description": {
"type": "string" "type": "string"
}, },
"fields": {
"description": "Future",
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@ -1388,6 +1421,12 @@ const docTemplate = `{
"description": { "description": {
"type": "string" "type": "string"
}, },
"fields": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": { "id": {
"type": "string" "type": "string"
}, },

View file

@ -344,7 +344,7 @@
"application/json" "application/json"
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "imports items into the database", "summary": "imports items into the database",
"parameters": [ "parameters": [
@ -407,7 +407,7 @@
"application/octet-stream" "application/octet-stream"
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "retrieves an attachment for an item", "summary": "retrieves an attachment for an item",
"parameters": [ "parameters": [
@ -444,7 +444,7 @@
"application/octet-stream" "application/octet-stream"
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "retrieves an attachment for an item", "summary": "retrieves an attachment for an item",
"parameters": [ "parameters": [
@ -479,7 +479,7 @@
} }
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "retrieves an attachment for an item", "summary": "retrieves an attachment for an item",
"parameters": [ "parameters": [
@ -523,7 +523,7 @@
} }
], ],
"tags": [ "tags": [
"Items" "Items Attachments"
], ],
"summary": "retrieves an attachment for an item", "summary": "retrieves an attachment for an item",
"parameters": [ "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": { "repo.ItemOut": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -1263,6 +1289,13 @@
"description": { "description": {
"type": "string" "type": "string"
}, },
"fields": {
"description": "Future",
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@ -1380,6 +1413,12 @@
"description": { "description": {
"type": "string" "type": "string"
}, },
"fields": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": { "id": {
"type": "string" "type": "string"
}, },

View file

@ -63,6 +63,23 @@ definitions:
name: name:
type: string type: string
type: object 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: repo.ItemOut:
properties: properties:
attachments: attachments:
@ -73,6 +90,11 @@ definitions:
type: string type: string
description: description:
type: string type: string
fields:
description: Future
items:
$ref: '#/definitions/repo.ItemField'
type: array
id: id:
type: string type: string
insured: insured:
@ -153,6 +175,10 @@ definitions:
properties: properties:
description: description:
type: string type: string
fields:
items:
$ref: '#/definitions/repo.ItemField'
type: array
id: id:
type: string type: string
insured: insured:
@ -653,7 +679,7 @@ paths:
- Bearer: [] - Bearer: []
summary: imports items into the database summary: imports items into the database
tags: tags:
- Items - Items Attachments
/v1/items/{id}/attachments/{attachment_id}: /v1/items/{id}/attachments/{attachment_id}:
delete: delete:
parameters: parameters:
@ -674,7 +700,7 @@ paths:
- Bearer: [] - Bearer: []
summary: retrieves an attachment for an item summary: retrieves an attachment for an item
tags: tags:
- Items - Items Attachments
get: get:
parameters: parameters:
- description: Item ID - description: Item ID
@ -698,7 +724,7 @@ paths:
- Bearer: [] - Bearer: []
summary: retrieves an attachment for an item summary: retrieves an attachment for an item
tags: tags:
- Items - Items Attachments
put: put:
parameters: parameters:
- description: Item ID - description: Item ID
@ -726,7 +752,7 @@ paths:
- Bearer: [] - Bearer: []
summary: retrieves an attachment for an item summary: retrieves an attachment for an item
tags: tags:
- Items - Items Attachments
/v1/items/{id}/attachments/download: /v1/items/{id}/attachments/download:
get: get:
parameters: parameters:
@ -749,7 +775,7 @@ paths:
- Bearer: [] - Bearer: []
summary: retrieves an attachment for an item summary: retrieves an attachment for an item
tags: tags:
- Items - Items Attachments
/v1/items/import: /v1/items/import:
post: post:
parameters: parameters:

View file

@ -22,7 +22,7 @@ type (
// HandleItemsImport godocs // HandleItemsImport godocs
// @Summary imports items into the database // @Summary imports items into the database
// @Tags Items // @Tags Items Attachments
// @Produce json // @Produce json
// @Param id path string true "Item ID" // @Param id path string true "Item ID"
// @Param file formData file true "File attachment" // @Param file formData file true "File attachment"
@ -99,7 +99,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
// HandleItemAttachmentGet godocs // HandleItemAttachmentGet godocs
// @Summary retrieves an attachment for an item // @Summary retrieves an attachment for an item
// @Tags Items // @Tags Items Attachments
// @Produce application/octet-stream // @Produce application/octet-stream
// @Param id path string true "Item ID" // @Param id path string true "Item ID"
// @Param token query string true "Attachment token" // @Param token query string true "Attachment token"
@ -126,7 +126,7 @@ func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc {
// HandleItemAttachmentToken godocs // HandleItemAttachmentToken godocs
// @Summary retrieves an attachment for an item // @Summary retrieves an attachment for an item
// @Tags Items // @Tags Items Attachments
// @Produce application/octet-stream // @Produce application/octet-stream
// @Param id path string true "Item ID" // @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID" // @Param attachment_id path string true "Attachment ID"
@ -139,7 +139,7 @@ func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc {
// HandleItemAttachmentDelete godocs // HandleItemAttachmentDelete godocs
// @Summary retrieves an attachment for an item // @Summary retrieves an attachment for an item
// @Tags Items // @Tags Items Attachments
// @Param id path string true "Item ID" // @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID" // @Param attachment_id path string true "Attachment ID"
// @Success 204 // @Success 204
@ -151,7 +151,7 @@ func (ctrl *V1Controller) HandleItemAttachmentDelete() http.HandlerFunc {
// HandleItemAttachmentUpdate godocs // HandleItemAttachmentUpdate godocs
// @Summary retrieves an attachment for an item // @Summary retrieves an attachment for an item
// @Tags Items // @Tags Items Attachments
// @Param id path string true "Item ID" // @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID" // @Param attachment_id path string true "Attachment ID"
// @Param payload body repo.ItemAttachmentUpdate true "Attachment Update" // @Param payload body repo.ItemAttachmentUpdate true "Attachment Update"

View file

@ -8,6 +8,7 @@ import (
"github.com/hay-kot/homebox/backend/ent" "github.com/hay-kot/homebox/backend/ent"
"github.com/hay-kot/homebox/backend/ent/group" "github.com/hay-kot/homebox/backend/ent/group"
"github.com/hay-kot/homebox/backend/ent/item" "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/label"
"github.com/hay-kot/homebox/backend/ent/location" "github.com/hay-kot/homebox/backend/ent/location"
"github.com/hay-kot/homebox/backend/ent/predicate" "github.com/hay-kot/homebox/backend/ent/predicate"
@ -27,6 +28,16 @@ type (
SortBy string `json:"sortBy"` 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 { ItemCreate struct {
ImportRef string `json:"-"` ImportRef string `json:"-"`
Name string `json:"name"` Name string `json:"name"`
@ -69,8 +80,8 @@ type (
SoldNotes string `json:"soldNotes"` SoldNotes string `json:"soldNotes"`
// Extras // Extras
Notes string `json:"notes"` Notes string `json:"notes"`
// Fields []*FieldSummary `json:"fields"` Fields []ItemField `json:"fields"`
} }
ItemSummary struct { ItemSummary struct {
@ -116,7 +127,7 @@ type (
Attachments []ItemAttachment `json:"attachments"` Attachments []ItemAttachment `json:"attachments"`
// Future // Future
// Fields []*FieldSummary `json:"fields"` Fields []ItemField `json:"fields"`
} }
) )
@ -156,12 +167,33 @@ var (
mapItemOutErr = mapTErrFunc(mapItemOut) 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 { func mapItemOut(item *ent.Item) ItemOut {
var attachments []ItemAttachment var attachments []ItemAttachment
if item.Edges.Attachments != nil { if item.Edges.Attachments != nil {
attachments = mapEach(item.Edges.Attachments, ToItemAttachment) attachments = mapEach(item.Edges.Attachments, ToItemAttachment)
} }
var fields []ItemField
if item.Edges.Fields != nil {
fields = mapFields(item.Edges.Fields)
}
return ItemOut{ return ItemOut{
ItemSummary: mapItemSummary(item), ItemSummary: mapItemSummary(item),
LifetimeWarranty: item.LifetimeWarranty, LifetimeWarranty: item.LifetimeWarranty,
@ -187,6 +219,7 @@ func mapItemOut(item *ent.Item) ItemOut {
// Extras // Extras
Notes: item.Notes, Notes: item.Notes,
Attachments: attachments, Attachments: attachments,
Fields: fields,
} }
} }
@ -370,5 +403,63 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, gid uuid.UUID, data
return ItemOut{}, err 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) return e.GetOne(ctx, data.ID)
} }

View file

@ -50,17 +50,40 @@
const selectedIdx = ref(-1); const selectedIdx = ref(-1);
const internalSelected = useVModel(props, "modelValue", emit); const internalSelected = useVModel(props, "modelValue", emit);
const internalValue = useVModel(props, "value", emit);
watch(selectedIdx, newVal => { watch(selectedIdx, newVal => {
internalSelected.value = props.items[newVal]; internalSelected.value = props.items[newVal];
}); });
watch(internalSelected, newVal => { watch(selectedIdx, newVal => {
if (props.valueKey) { 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
function compare(a: any, b: any): boolean { function compare(a: any, b: any): boolean {
if (a === b) { if (a === b) {
@ -73,15 +96,4 @@
return JSON.stringify(a) === JSON.stringify(b); return JSON.stringify(a) === JSON.stringify(b);
} }
watch(
internalSelected,
() => {
const idx = props.items.findIndex(item => compare(item, internalSelected.value));
selectedIdx.value = idx;
},
{
immediate: true,
}
);
</script> </script>

View file

@ -2,11 +2,23 @@ import { faker } from "@faker-js/faker";
import { expect } from "vitest"; import { expect } from "vitest";
import { overrideParts } from "../../base/urls"; import { overrideParts } from "../../base/urls";
import { PublicApi } from "../../public"; 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 * as config from "../../../../test/config";
import { UserClient } from "../../user"; import { UserClient } from "../../user";
import { Requests } from "../../../requests"; 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 * Returns a random user registration object that can be
* used to signup a new user. * used to signup a new user.
@ -72,6 +84,7 @@ export const factories = {
user, user,
location, location,
label, label,
itemField,
client: { client: {
public: publicClient, public: publicClient,
user: userClient, user: userClient,

View file

@ -1,7 +1,9 @@
import { faker } from "@faker-js/faker";
import { describe, test, expect } from "vitest"; 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 { AttachmentTypes } from "../../types/non-generated";
import { UserClient } from "../../user"; import { UserClient } from "../../user";
import { factories } from "../factories";
import { sharedUserClient } from "../test-utils"; import { sharedUserClient } from "../test-utils";
describe("user should be able to create an item and add an attachment", () => { 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); api.items.delete(item.id);
await cleanup(); 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();
});
}); });

View file

@ -51,10 +51,23 @@ export interface ItemCreate {
name: string; name: string;
} }
export interface ItemField {
booleanValue: boolean;
id: string;
name: string;
numberValue: number;
textValue: string;
timeValue: string;
type: string;
}
export interface ItemOut { export interface ItemOut {
attachments: ItemAttachment[]; attachments: ItemAttachment[];
createdAt: Date; createdAt: Date;
description: string; description: string;
/** Future */
fields: ItemField[];
id: string; id: string;
insured: boolean; insured: boolean;
labels: LabelSummary[]; labels: LabelSummary[];
@ -108,6 +121,7 @@ export interface ItemSummary {
export interface ItemUpdate { export interface ItemUpdate {
description: string; description: string;
fields: ItemField[];
id: string; id: string;
insured: boolean; insured: boolean;
labelIds: string[]; labelIds: string[];

View file

@ -278,6 +278,34 @@
toast.success("Attachment updated"); 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,
});
}
</script> </script>
<template> <template>
@ -364,6 +392,31 @@
</div> </div>
</div> </div>
<BaseCard>
<template #title> Custom Fields </template>
<div class="px-5 divide-y divide-gray-300 space-y-4">
<div
v-for="(field, idx) in item.fields"
:key="`field-${idx}`"
class="grid grid-cols-2 md:grid-cols-4 gap-2"
>
<!-- <FormSelect v-model:value="field.type" label="Field Type" :items="fieldTypes" value-key="value" /> -->
<FormTextField v-model="field.name" label="Name" />
<div class="flex items-end col-span-3">
<FormTextField v-model="field.textValue" label="Value" />
<div class="tooltip" data-tip="Delete">
<button class="btn btn-sm btn-square mb-2 ml-2" @click="item.fields.splice(idx, 1)">
<Icon name="mdi-delete" />
</button>
</div>
</div>
</div>
</div>
<div class="px-5 pb-4 mt-4 flex justify-end">
<BaseButton size="sm" @click="addField"> Add </BaseButton>
</div>
</BaseCard>
<div <div
v-if="!preferences.editorSimpleView" v-if="!preferences.editorSimpleView"
ref="attDropZone" ref="attDropZone"

View file

@ -96,6 +96,10 @@
name: "Notes", name: "Notes",
text: item.value?.notes, text: item.value?.notes,
}, },
...item.value.fields.map(field => ({
name: field.name,
text: field.textValue,
})),
]; ];
}); });