Compare commits

...

5 Commits

Author SHA1 Message Date
Hayden
2d34557f69 feat: link implementation (#100)
* link implementation

* add docs for links

* use btn instead of badge
2022-10-19 21:31:08 -08:00
dependabot[bot]
97a34475c8 fix(deps): bump github.com/swaggo/swag from 1.8.6 to 1.8.7 in /backend (#97)
Bumps [github.com/swaggo/swag](https://github.com/swaggo/swag) from 1.8.6 to 1.8.7.
- [Release notes](https://github.com/swaggo/swag/releases)
- [Changelog](https://github.com/swaggo/swag/blob/master/.goreleaser.yml)
- [Commits](https://github.com/swaggo/swag/compare/v1.8.6...v1.8.7)

---
updated-dependencies:
- dependency-name: github.com/swaggo/swag
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-18 21:47:46 -08:00
Hayden
18488f5b15 refactor: cleanup-api-functions (#94)
* cleanup items endpoints

* refactor group routes

* refactor labels routes

* remove old partial

* refactor location routes

* formatting

* update names

* cleanup func

* speedup test runner with disable hasher

* remove duplicate code
2022-10-16 18:50:44 -08:00
Hayden
434f1fa411 add support for custom text fields 2022-10-15 21:41:27 -08:00
Hayden
57f9372e49 feat: add receipt support for attachments (#89)
* add receipt support for attachments

* fix show logic
2022-10-15 19:45:36 -08:00
35 changed files with 783 additions and 386 deletions

View File

@@ -2,14 +2,14 @@ version: "3"
env:
HBOX_STORAGE_SQLITE_URL: .data/homebox.db?_fk=1
UNSAFE_DISABLE_PASSWORD_PROJECTION: "yes_i_am_sure"
tasks:
setup:
desc: Install dependencies
cmds:
- go install github.com/swaggo/swag/cmd/swag@latest
- cd backend && go mod tidy
- cd frontend && pnpm install --shamefully-hoist
- go install github.com/swaggo/swag/cmd/swag@latest
- cd backend && go mod tidy
- cd frontend && pnpm install --shamefully-hoist
generate:
desc: |
Generates collateral files from the backend project

View File

@@ -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": [
@@ -1135,27 +1135,6 @@ const docTemplate = `{
}
}
}
},
"/v1/users/self/password": {
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Update the current user's password // TODO:",
"responses": {
"204": {
"description": "No Content"
}
}
}
}
},
"definitions": {
@@ -1256,6 +1235,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 +1276,13 @@ const docTemplate = `{
"description": {
"type": "string"
},
"fields": {
"description": "Future",
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": {
"type": "string"
},
@@ -1388,6 +1400,12 @@ const docTemplate = `{
"description": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": {
"type": "string"
},

View File

@@ -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": [
@@ -1127,27 +1127,6 @@
}
}
}
},
"/v1/users/self/password": {
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Update the current user's password // TODO:",
"responses": {
"204": {
"description": "No Content"
}
}
}
}
},
"definitions": {
@@ -1248,6 +1227,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 +1268,13 @@
"description": {
"type": "string"
},
"fields": {
"description": "Future",
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": {
"type": "string"
},
@@ -1380,6 +1392,12 @@
"description": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": {
"type": "string"
},

View File

@@ -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:
@@ -1112,18 +1138,6 @@ paths:
summary: Update the current user
tags:
- User
/v1/users/self/password:
put:
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: 'Update the current user''s password // TODO:'
tags:
- User
securityDefinitions:
Bearer:
description: '"Type ''Bearer TOKEN'' to correctly set the API Key"'

View File

@@ -63,22 +63,6 @@ func (a *app) mwAuthToken(next http.Handler) http.Handler {
})
}
// mwAdminOnly is a middleware that extends the mwAuthToken middleware to only allow
// requests from superusers.
// func (a *app) mwAdminOnly(next http.Handler) http.Handler {
// mw := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// usr := services.UseUserCtx(r.Context())
// if !usr.IsSuperuser {
// server.RespondUnauthorized(w)
// return
// }
// next.ServeHTTP(w, r)
// })
// return a.mwAuthToken(mw)
// }
// mqStripTrailingSlash is a middleware that will strip trailing slashes from the request path.
func mwStripTrailingSlash(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -111,7 +95,6 @@ func (a *app) mwStructLogger(next http.Handler) http.Handler {
log.Info().
Str("id", middleware.GetReqID(r.Context())).
Str("url", url).
Str("method", r.Method).
Str("remote_addr", r.RemoteAddr).
Int("status", record.Status).

View File

@@ -65,7 +65,6 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf())
r.Put(v1Base("/users/self"), v1Ctrl.HandleUserSelfUpdate())
r.Delete(v1Base("/users/self"), v1Ctrl.HandleUserSelfDelete())
r.Put(v1Base("/users/self/password"), v1Ctrl.HandleUserUpdatePassword())
r.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout())
r.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh())
r.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword())

View File

@@ -33,6 +33,8 @@ type V1Controller struct {
}
type (
ReadyFunc func() bool
Build struct {
Version string `json:"version"`
Commit string `json:"commit"`
@@ -50,12 +52,9 @@ type (
)
func BaseUrlFunc(prefix string) func(s string) string {
v1Base := prefix + "/v1"
prefixFunc := func(s string) string {
return v1Base + s
return func(s string) string {
return prefix + "/v1" + s
}
return prefixFunc
}
func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) *V1Controller {
@@ -71,8 +70,6 @@ func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller))
return ctrl
}
type ReadyFunc func() bool
// HandleBase godoc
// @Summary Retrieves the basic information about the API
// @Tags Base

View File

@@ -5,30 +5,17 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/internal/services"
"github.com/hay-kot/homebox/backend/pkgs/server"
"github.com/rs/zerolog/log"
)
/*
This is where we put partial snippets/functions for actions that are commonly
used within the controller class. This _hopefully_ helps with code duplication
and makes it a little more consistent when error handling and logging.
*/
// partialParseIdAndUser parses the ID from the requests URL and pulls the user
// from the context. If either of these fail, it will return an error. When an error
// occurs it will also write the error to the response. As such, if an error is returned
// from this function you can return immediately without writing to the response.
func (ctrl *V1Controller) partialParseIdAndUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, *repo.UserOut, error) {
uid, err := uuid.Parse(chi.URLParam(r, "id"))
func (ctrl *V1Controller) routeID(w http.ResponseWriter, r *http.Request) (uuid.UUID, error) {
ID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
log.Err(err).Msg("failed to parse id")
server.RespondError(w, http.StatusBadRequest, err)
return uuid.Nil, &repo.UserOut{}, err
return uuid.Nil, err
}
user := services.UseUserCtx(r.Context())
return uid, user, nil
return ID, nil
}

View File

@@ -32,20 +32,7 @@ type (
// @Router /v1/groups [Get]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := services.NewContext(r.Context())
group, err := ctrl.svc.Group.Get(ctx)
if err != nil {
log.Err(err).Msg("failed to get group")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower caseÍ
server.Respond(w, http.StatusOK, group)
}
return ctrl.handleGroupGeneral()
}
// HandleGroupUpdate godoc
@@ -57,24 +44,41 @@ func (ctrl *V1Controller) HandleGroupGet() http.HandlerFunc {
// @Router /v1/groups [Put]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupUpdate() http.HandlerFunc {
return ctrl.handleGroupGeneral()
}
func (ctrl *V1Controller) handleGroupGeneral() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := repo.GroupUpdate{}
if err := server.Decode(r, &data); err != nil {
server.RespondError(w, http.StatusBadRequest, err)
return
}
ctx := services.NewContext(r.Context())
group, err := ctrl.svc.Group.UpdateGroup(ctx, data)
if err != nil {
log.Err(err).Msg("failed to update group")
server.RespondError(w, http.StatusInternalServerError, err)
return
switch r.Method {
case http.MethodGet:
group, err := ctrl.svc.Group.Get(ctx)
if err != nil {
log.Err(err).Msg("failed to get group")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower caseÍ
server.Respond(w, http.StatusOK, group)
case http.MethodPut:
data := repo.GroupUpdate{}
if err := server.Decode(r, &data); err != nil {
server.RespondError(w, http.StatusBadRequest, err)
return
}
group, err := ctrl.svc.Group.UpdateGroup(ctx, data)
if err != nil {
log.Err(err).Msg("failed to update group")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower case
server.Respond(w, http.StatusOK, group)
}
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower case
server.Respond(w, http.StatusOK, group)
}
}
@@ -89,7 +93,6 @@ func (ctrl *V1Controller) HandleGroupUpdate() http.HandlerFunc {
func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := GroupInvitationCreate{}
if err := server.Decode(r, &data); err != nil {
log.Err(err).Msg("failed to decode user registration data")
server.RespondError(w, http.StatusBadRequest, err)

View File

@@ -13,41 +13,6 @@ import (
"github.com/rs/zerolog/log"
)
func uuidList(params url.Values, key string) []uuid.UUID {
var ids []uuid.UUID
for _, id := range params[key] {
uid, err := uuid.Parse(id)
if err != nil {
continue
}
ids = append(ids, uid)
}
return ids
}
func intOrNegativeOne(s string) int {
i, err := strconv.Atoi(s)
if err != nil {
return -1
}
return i
}
func extractQuery(r *http.Request) repo.ItemQuery {
params := r.URL.Query()
page := intOrNegativeOne(params.Get("page"))
perPage := intOrNegativeOne(params.Get("perPage"))
return repo.ItemQuery{
Page: page,
PageSize: perPage,
Search: params.Get("q"),
LocationIDs: uuidList(params, "locations"),
LabelIDs: uuidList(params, "labels"),
}
}
// HandleItemsGetAll godoc
// @Summary Get All Items
// @Tags Items
@@ -61,6 +26,38 @@ func extractQuery(r *http.Request) repo.ItemQuery {
// @Router /v1/items [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc {
uuidList := func(params url.Values, key string) []uuid.UUID {
var ids []uuid.UUID
for _, id := range params[key] {
uid, err := uuid.Parse(id)
if err != nil {
continue
}
ids = append(ids, uid)
}
return ids
}
intOrNegativeOne := func(s string) int {
i, err := strconv.Atoi(s)
if err != nil {
return -1
}
return i
}
extractQuery := func(r *http.Request) repo.ItemQuery {
params := r.URL.Query()
return repo.ItemQuery{
Page: intOrNegativeOne(params.Get("page")),
PageSize: intOrNegativeOne(params.Get("perPage")),
Search: params.Get("q"),
LocationIDs: uuidList(params, "locations"),
LabelIDs: uuidList(params, "labels"),
}
}
return func(w http.ResponseWriter, r *http.Request) {
ctx := services.NewContext(r.Context())
items, err := ctrl.svc.Items.Query(ctx, extractQuery(r))
@@ -102,31 +99,6 @@ func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc {
}
}
// HandleItemDelete godocs
// @Summary deletes a item
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Success 204
// @Router /v1/items/{id} [DELETE]
// @Security Bearer
func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r)
if err != nil {
return
}
err = ctrl.svc.Items.Delete(r.Context(), user.GroupID, uid)
if err != nil {
log.Err(err).Msg("failed to delete item")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusNoContent, nil)
}
}
// HandleItemGet godocs
// @Summary Gets a item and fields
// @Tags Items
@@ -136,20 +108,19 @@ func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc {
// @Router /v1/items/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r)
if err != nil {
return
}
return ctrl.handleItemsGeneral()
}
items, err := ctrl.svc.Items.GetOne(r.Context(), user.GroupID, uid)
if err != nil {
log.Err(err).Msg("failed to get item")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, items)
}
// HandleItemDelete godocs
// @Summary deletes a item
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Success 204
// @Router /v1/items/{id} [DELETE]
// @Security Bearer
func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc {
return ctrl.handleItemsGeneral()
}
// HandleItemUpdate godocs
@@ -162,26 +133,53 @@ func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc {
// @Router /v1/items/{id} [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleItemUpdate() http.HandlerFunc {
return ctrl.handleItemsGeneral()
}
func (ctrl *V1Controller) handleItemsGeneral() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body := repo.ItemUpdate{}
if err := server.Decode(r, &body); err != nil {
log.Err(err).Msg("failed to decode request body")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
uid, user, err := ctrl.partialParseIdAndUser(w, r)
ctx := services.NewContext(r.Context())
ID, err := ctrl.routeID(w, r)
if err != nil {
return
}
body.ID = uid
result, err := ctrl.svc.Items.Update(r.Context(), user.GroupID, body)
if err != nil {
log.Err(err).Msg("failed to update item")
server.RespondServerError(w)
switch r.Method {
case http.MethodGet:
items, err := ctrl.svc.Items.GetOne(r.Context(), ctx.GID, ID)
if err != nil {
log.Err(err).Msg("failed to get item")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, items)
return
case http.MethodDelete:
err = ctrl.svc.Items.Delete(r.Context(), ctx.GID, ID)
if err != nil {
log.Err(err).Msg("failed to delete item")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusNoContent, nil)
return
case http.MethodPut:
body := repo.ItemUpdate{}
if err := server.Decode(r, &body); err != nil {
log.Err(err).Msg("failed to decode request body")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
body.ID = ID
result, err := ctrl.svc.Items.Update(r.Context(), ctx.GID, body)
if err != nil {
log.Err(err).Msg("failed to update item")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, result)
}
server.Respond(w, http.StatusOK, result)
}
}

View File

@@ -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"
@@ -72,7 +72,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
attachmentType = attachment.TypeAttachment.String()
}
id, _, err := ctrl.partialParseIdAndUser(w, r)
id, err := ctrl.routeID(w, r)
if err != nil {
return
}
@@ -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"
@@ -163,7 +163,7 @@ func (ctrl *V1Controller) HandleItemAttachmentUpdate() http.HandlerFunc {
}
func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r)
ID, err := ctrl.routeID(w, r)
if err != nil {
return
}
@@ -181,7 +181,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
// Token Handler
case http.MethodGet:
token, err := ctrl.svc.Items.AttachmentToken(ctx, uid, attachmentId)
token, err := ctrl.svc.Items.AttachmentToken(ctx, ID, attachmentId)
if err != nil {
switch err {
case services.ErrNotFound:
@@ -210,7 +210,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
// Delete Attachment Handler
case http.MethodDelete:
err = ctrl.svc.Items.AttachmentDelete(r.Context(), user.GroupID, uid, attachmentId)
err = ctrl.svc.Items.AttachmentDelete(r.Context(), ctx.GID, ID, attachmentId)
if err != nil {
log.Err(err).Msg("failed to delete attachment")
server.RespondServerError(w)
@@ -230,7 +230,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
}
attachment.ID = attachmentId
val, err := ctrl.svc.Items.AttachmentUpdate(ctx, uid, &attachment)
val, err := ctrl.svc.Items.AttachmentUpdate(ctx, ID, &attachment)
if err != nil {
log.Err(err).Msg("failed to delete attachment")
server.RespondServerError(w)

View File

@@ -56,7 +56,6 @@ func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc {
}
server.Respond(w, http.StatusCreated, label)
}
}
@@ -69,20 +68,7 @@ func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc {
// @Router /v1/labels/{id} [DELETE]
// @Security Bearer
func (ctrl *V1Controller) HandleLabelDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r)
if err != nil {
return
}
err = ctrl.svc.Labels.Delete(r.Context(), user.GroupID, uid)
if err != nil {
log.Err(err).Msg("error deleting label")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusNoContent, nil)
}
return ctrl.handleLabelsGeneral()
}
// HandleLabelGet godocs
@@ -94,27 +80,7 @@ func (ctrl *V1Controller) HandleLabelDelete() http.HandlerFunc {
// @Router /v1/labels/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleLabelGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r)
if err != nil {
return
}
labels, err := ctrl.svc.Labels.Get(r.Context(), user.GroupID, uid)
if err != nil {
if ent.IsNotFound(err) {
log.Err(err).
Str("id", uid.String()).
Msg("label not found")
server.RespondError(w, http.StatusNotFound, err)
return
}
log.Err(err).Msg("error getting label")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, labels)
}
return ctrl.handleLabelsGeneral()
}
// HandleLabelUpdate godocs
@@ -126,25 +92,59 @@ func (ctrl *V1Controller) HandleLabelGet() http.HandlerFunc {
// @Router /v1/labels/{id} [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleLabelUpdate() http.HandlerFunc {
return ctrl.handleLabelsGeneral()
}
func (ctrl *V1Controller) handleLabelsGeneral() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body := repo.LabelUpdate{}
if err := server.Decode(r, &body); err != nil {
log.Err(err).Msg("error decoding label update data")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
uid, user, err := ctrl.partialParseIdAndUser(w, r)
ctx := services.NewContext(r.Context())
ID, err := ctrl.routeID(w, r)
if err != nil {
return
}
body.ID = uid
result, err := ctrl.svc.Labels.Update(r.Context(), user.GroupID, body)
if err != nil {
log.Err(err).Msg("error updating label")
server.RespondServerError(w)
return
switch r.Method {
case http.MethodGet:
labels, err := ctrl.svc.Labels.Get(r.Context(), ctx.GID, ID)
if err != nil {
if ent.IsNotFound(err) {
log.Err(err).
Str("id", ID.String()).
Msg("label not found")
server.RespondError(w, http.StatusNotFound, err)
return
}
log.Err(err).Msg("error getting label")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, labels)
case http.MethodDelete:
err = ctrl.svc.Labels.Delete(r.Context(), ctx.GID, ID)
if err != nil {
log.Err(err).Msg("error deleting label")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusNoContent, nil)
case http.MethodPut:
body := repo.LabelUpdate{}
if err := server.Decode(r, &body); err != nil {
log.Err(err).Msg("error decoding label update data")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
body.ID = ID
result, err := ctrl.svc.Labels.Update(r.Context(), ctx.GID, body)
if err != nil {
log.Err(err).Msg("error updating label")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, result)
}
server.Respond(w, http.StatusOK, result)
}
}

View File

@@ -69,20 +69,7 @@ func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc {
// @Router /v1/locations/{id} [DELETE]
// @Security Bearer
func (ctrl *V1Controller) HandleLocationDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r)
if err != nil {
return
}
err = ctrl.svc.Location.Delete(r.Context(), user.GroupID, uid)
if err != nil {
log.Err(err).Msg("failed to delete location")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusNoContent, nil)
}
return ctrl.handleLocationGeneral()
}
// HandleLocationGet godocs
@@ -94,32 +81,7 @@ func (ctrl *V1Controller) HandleLocationDelete() http.HandlerFunc {
// @Router /v1/locations/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r)
if err != nil {
return
}
location, err := ctrl.svc.Location.GetOne(r.Context(), user.GroupID, uid)
if err != nil {
if ent.IsNotFound(err) {
log.Err(err).
Str("id", uid.String()).
Str("gid", user.GroupID.String()).
Msg("location not found")
server.RespondError(w, http.StatusNotFound, err)
return
}
log.Err(err).
Str("id", uid.String()).
Str("gid", user.GroupID.String()).
Msg("failed to get location")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, location)
}
return ctrl.handleLocationGeneral()
}
// HandleLocationUpdate godocs
@@ -131,27 +93,61 @@ func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc {
// @Router /v1/locations/{id} [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleLocationUpdate() http.HandlerFunc {
return ctrl.handleLocationGeneral()
}
func (ctrl *V1Controller) handleLocationGeneral() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body := repo.LocationUpdate{}
if err := server.Decode(r, &body); err != nil {
log.Err(err).Msg("failed to decode location update data")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
uid, user, err := ctrl.partialParseIdAndUser(w, r)
ctx := services.NewContext(r.Context())
ID, err := ctrl.routeID(w, r)
if err != nil {
return
}
body.ID = uid
switch r.Method {
case http.MethodGet:
location, err := ctrl.svc.Location.GetOne(r.Context(), ctx.GID, ID)
if err != nil {
l := log.Err(err).
Str("ID", ID.String()).
Str("GID", ctx.GID.String())
result, err := ctrl.svc.Location.Update(r.Context(), user.GroupID, body)
if err != nil {
log.Err(err).Msg("failed to update location")
server.RespondServerError(w)
return
if ent.IsNotFound(err) {
l.Msg("location not found")
server.RespondError(w, http.StatusNotFound, err)
return
}
l.Msg("failed to get location")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, location)
case http.MethodPut:
body := repo.LocationUpdate{}
if err := server.Decode(r, &body); err != nil {
log.Err(err).Msg("failed to decode location update data")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
body.ID = ID
result, err := ctrl.svc.Location.Update(r.Context(), ctx.GID, body)
if err != nil {
log.Err(err).Msg("failed to update location")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, result)
case http.MethodDelete:
err = ctrl.svc.Location.Delete(r.Context(), ctx.GID, ID)
if err != nil {
log.Err(err).Msg("failed to delete location")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusNoContent, nil)
}
server.Respond(w, http.StatusOK, result)
}
}

View File

@@ -85,7 +85,6 @@ func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc {
newData, err := ctrl.svc.User.UpdateSelf(r.Context(), actor.ID, updateData)
if err != nil {
server.RespondError(w, http.StatusInternalServerError, err)
return
}
@@ -94,18 +93,6 @@ func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc {
}
}
// HandleUserUpdatePassword godoc
// @Summary Update the current user's password // TODO:
// @Tags User
// @Produce json
// @Success 204
// @Router /v1/users/self/password [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleUserUpdatePassword() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
}
}
// HandleUserSelfDelete godoc
// @Summary Deletes the user account
// @Tags User

View File

@@ -95,6 +95,7 @@ const (
TypeManual Type = "manual"
TypeWarranty Type = "warranty"
TypeAttachment Type = "attachment"
TypeReceipt Type = "receipt"
)
func (_type Type) String() string {
@@ -104,7 +105,7 @@ func (_type Type) String() string {
// TypeValidator is a validator for the "type" field enum values. It is called by the builders before save.
func TypeValidator(_type Type) error {
switch _type {
case TypePhoto, TypeManual, TypeWarranty, TypeAttachment:
case TypePhoto, TypeManual, TypeWarranty, TypeAttachment, TypeReceipt:
return nil
default:
return fmt.Errorf("attachment: invalid enum value for type field: %q", _type)

View File

@@ -13,7 +13,7 @@ var (
{Name: "id", Type: field.TypeUUID},
{Name: "created_at", Type: field.TypeTime},
{Name: "updated_at", Type: field.TypeTime},
{Name: "type", Type: field.TypeEnum, Enums: []string{"photo", "manual", "warranty", "attachment"}, Default: "attachment"},
{Name: "type", Type: field.TypeEnum, Enums: []string{"photo", "manual", "warranty", "attachment", "receipt"}, Default: "attachment"},
{Name: "document_attachments", Type: field.TypeUUID},
{Name: "item_attachments", Type: field.TypeUUID},
}

View File

@@ -22,7 +22,7 @@ func (Attachment) Mixin() []ent.Mixin {
func (Attachment) Fields() []ent.Field {
return []ent.Field{
field.Enum("type").
Values("photo", "manual", "warranty", "attachment").
Values("photo", "manual", "warranty", "attachment", "receipt").
Default("attachment"),
}
}

View File

@@ -12,7 +12,7 @@ require (
github.com/rs/zerolog v1.28.0
github.com/stretchr/testify v1.8.0
github.com/swaggo/http-swagger v1.3.3
github.com/swaggo/swag v1.8.6
github.com/swaggo/swag v1.8.7
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90
)

View File

@@ -90,8 +90,8 @@ github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowN
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
github.com/swaggo/swag v1.8.6 h1:2rgOaLbonWu1PLP6G+/rYjSvPg0jQE0HtrEKuE380eg=
github.com/swaggo/swag v1.8.6/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
github.com/swaggo/swag v1.8.7 h1:2K9ivTD3teEO+2fXV6zrZKDqk5IuU2aJtBDo8U7omWU=
github.com/swaggo/swag v1.8.7/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/zclconf/go-cty v1.11.0 h1:726SxLdi2SDnjY+BStqB9J1hNp4+2WlzyXLuimibIe0=
github.com/zclconf/go-cty v1.11.0/go.mod h1:s9IfD1LK5ccNMSWCVFCE2rJfHiZgi7JijgeWIMfhLvA=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=

View File

@@ -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)
}

View File

@@ -1,13 +1,37 @@
package hasher
import "golang.org/x/crypto/bcrypt"
import (
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
)
var enabled = true
func init() {
disableHas := os.Getenv("UNSAFE_DISABLE_PASSWORD_PROJECTION") == "yes_i_am_sure"
if disableHas {
fmt.Println("WARNING: Password projection is disabled. This is unsafe in production.")
enabled = false
}
}
func HashPassword(password string) (string, error) {
if !enabled {
return password, nil
}
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
func CheckPasswordHash(password, hash string) bool {
if !enabled {
return password == hash
}
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View File

@@ -13,7 +13,7 @@ docker run --name=homebox \
## Docker-Compose
```yml
```yaml
version: "3.4"
services:

16
docs/docs/tips-tricks.md Normal file
View File

@@ -0,0 +1,16 @@
# Tips and Tricks
## Custom Fields
Custom fields are a great way to add any extra information to your item. The following types are supported:
- [x] Text
- [ ] Integer (Future)
- [ ] Boolean (Future)
- [ ] Timestamp (Future)
Custom fields are appended to the main details section of your item.
!!! tip
Homebox Custom Fields also have special support for URLs. Provide a URL (`https://google.com`) and it will be automatically converted to a clickable link in the UI. Optionally, you can also use markdown syntax to add a custom text to the button. `[Google](https://google.com)`

View File

@@ -47,5 +47,6 @@ markdown_extensions:
nav:
- Home: index.md
- Quick Start: quick-start.md
- Tips and Tricks: tips-tricks.md
- Importing Data: import-csv.md
- Building The Binary: build.md

View File

@@ -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,
}
);
</script>

View File

@@ -9,6 +9,14 @@
<slot :name="detail.slot || detail.name" v-bind="{ detail }">
<DateTime v-if="detail.type == 'date'" :date="detail.text" />
<Currency v-else-if="detail.type == 'currency'" :amount="detail.text" />
<template v-else-if="detail.type === 'link'">
<div class="tooltip tooltip-primary tooltip-right" :data-tip="detail.href">
<a class="btn btn-primary btn-xs" :href="detail.href" target="_blank">
<Icon name="mdi-open-in-new" class="mr-2 swap-on"></Icon>
{{ detail.text }}
</a>
</div>
</template>
<template v-else>
{{ detail.text }}
</template>

View File

@@ -15,7 +15,13 @@ type CurrencyDetail = BaseDetail & {
text: string;
};
export type CustomDetail = DateDetail | CurrencyDetail;
type LinkDetail = BaseDetail & {
type: "link";
text: string;
href: string;
};
export type CustomDetail = DateDetail | CurrencyDetail | LinkDetail;
export type Detail = BaseDetail & {
text: StringLike;

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { maybeUrl } from "./utils";
describe("maybeURL works as expected", () => {
test("basic valid URL case", () => {
const result = maybeUrl("https://example.com");
expect(result.isUrl).toBe(true);
expect(result.url).toBe("https://example.com");
expect(result.text).toBe("Link");
});
test("special URL syntax", () => {
const result = maybeUrl("[My Text](http://example.com)");
expect(result.isUrl).toBe(true);
expect(result.url).toBe("http://example.com");
expect(result.text).toBe("My Text");
});
test("not a url", () => {
const result = maybeUrl("not a url");
expect(result.isUrl).toBe(false);
expect(result.url).toBe("");
expect(result.text).toBe("");
});
test("malformed special syntax", () => {
const result = maybeUrl("[My Text(http://example.com)");
expect(result.isUrl).toBe(false);
expect(result.url).toBe("");
expect(result.text).toBe("");
});
});

View File

@@ -33,3 +33,35 @@ export function fmtCurrency(value: number | string, currency = "USD", locale = "
});
return formatter.format(value);
}
export type MaybeUrlResult = {
isUrl: boolean;
url: string;
text: string;
};
export function maybeUrl(str: string): MaybeUrlResult {
const result: MaybeUrlResult = {
isUrl: str.startsWith("http://") || str.startsWith("https://"),
url: "",
text: "",
};
if (!result.isUrl && !str.startsWith("[")) {
return result;
}
if (str.startsWith("[")) {
const match = str.match(/\[(.*)\]\((.*)\)/);
if (match && match.length === 3) {
result.isUrl = true;
result.text = match[1];
result.url = match[2];
}
} else {
result.url = str;
result.text = "Link";
}
return result;
}

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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[];

View File

@@ -3,6 +3,7 @@ export enum AttachmentTypes {
Manual = "manual",
Warranty = "warranty",
Attachment = "attachment",
Receipt = "receipt",
}
export type Result<T> = {

View File

@@ -190,6 +190,7 @@
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);
const dropReceipt = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Receipt);
async function uploadAttachment(files: File[] | null, type: AttachmentTypes) {
if (!files && files.length === 0) {
@@ -277,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,
});
}
</script>
<template>
@@ -363,6 +392,31 @@
</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
v-if="!preferences.editorSimpleView"
ref="attDropZone"
@@ -378,6 +432,7 @@
<DropZone @drop="dropWarranty"> Warranty </DropZone>
<DropZone @drop="dropManual"> Manual </DropZone>
<DropZone @drop="dropAttachment"> Attachment </DropZone>
<DropZone @drop="dropReceipt"> Receipt </DropZone>
</div>
<button
v-else

View File

@@ -31,6 +31,7 @@
attachments: ItemAttachment[];
warranty: ItemAttachment[];
manuals: ItemAttachment[];
receipts: ItemAttachment[];
};
const attachments = computed<FilteredAttachments>(() => {
@@ -40,6 +41,7 @@
attachments: [],
manuals: [],
warranty: [],
receipts: [],
};
}
@@ -51,6 +53,8 @@
acc.warranty.push(attachment);
} else if (attachment.type === "manual") {
acc.manuals.push(attachment);
} else if (attachment.type === "receipt") {
acc.receipts.push(attachment);
} else {
acc.attachments.push(attachment);
}
@@ -61,6 +65,7 @@
attachments: [] as ItemAttachment[],
warranty: [] as ItemAttachment[],
manuals: [] as ItemAttachment[],
receipts: [] as ItemAttachment[],
}
);
});
@@ -91,6 +96,25 @@
name: "Notes",
text: item.value?.notes,
},
...item.value.fields.map(field => {
/**
* Support Special URL Syntax
*/
const url = maybeUrl(field.textValue);
if (url.isUrl) {
return {
name: field.name,
text: url.text,
type: "link",
href: url.url,
};
}
return {
name: field.name,
text: field.textValue,
};
}),
];
});
@@ -103,7 +127,8 @@
attachments.value.photos.length > 0 ||
attachments.value.attachments.length > 0 ||
attachments.value.warranty.length > 0 ||
attachments.value.manuals.length > 0
attachments.value.manuals.length > 0 ||
attachments.value.receipts.length > 0
);
});
@@ -134,6 +159,10 @@
push("Manuals");
}
if (attachments.value.receipts.length > 0) {
push("Receipts");
}
return details;
});
@@ -323,6 +352,13 @@
:item-id="item.id"
/>
</template>
<template #receipts>
<ItemAttachmentsList
v-if="attachments.receipts.length > 0"
:attachments="attachments.receipts"
:item-id="item.id"
/>
</template>
</DetailsSection>
</BaseCard>