From 30014a77cac85886bf8a5afe778abcc1b3332a65 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Wed, 12 Oct 2022 21:13:07 -0800 Subject: [PATCH] feat: expanded search for items (#46) * expanded search for items * range domain from email to example * implement pagination for items --- backend/app/api/demo.go | 2 +- backend/app/api/docs/docs.go | 105 ++++++++++---- backend/app/api/docs/swagger.json | 105 ++++++++++---- backend/app/api/docs/swagger.yaml | 76 +++++++--- backend/app/api/main.go | 20 +-- backend/app/api/v1/controller.go | 10 +- backend/app/api/v1/v1_ctrl_auth.go | 42 +++--- backend/app/api/v1/v1_ctrl_group.go | 14 +- backend/app/api/v1/v1_ctrl_items.go | 133 ++++++++++++------ .../app/api/v1/v1_ctrl_items_attachments.go | 84 +++++------ backend/app/api/v1/v1_ctrl_labels.go | 68 ++++----- backend/app/api/v1/v1_ctrl_locations.go | 68 ++++----- backend/app/api/v1/v1_ctrl_user.go | 74 +++++----- backend/internal/repo/pagination.go | 12 ++ backend/internal/repo/repo_items.go | 68 +++++++++ backend/internal/services/service_items.go | 4 + backend/pkgs/faker/random.go | 2 +- frontend/components/App/Header.vue | 4 + frontend/components/Form/Multiselect.vue | 3 +- frontend/components/Form/TextField.vue | 8 +- frontend/composables/use-min-loader.ts | 32 +++++ frontend/lib/api/classes/items.ts | 15 +- frontend/lib/api/classes/labels.ts | 2 +- frontend/lib/api/classes/locations.ts | 2 +- frontend/lib/api/classes/types/index.ts | 3 - frontend/lib/api/types/data-contracts.ts | 1 + frontend/lib/api/types/non-generated.ts | 11 ++ frontend/pages/home.vue | 13 +- frontend/pages/index.vue | 6 +- frontend/pages/items.vue | 109 ++++++++++++++ scripts/process-types.py | 1 + 31 files changed, 751 insertions(+), 346 deletions(-) create mode 100644 backend/internal/repo/pagination.go create mode 100644 frontend/composables/use-min-loader.ts delete mode 100644 frontend/lib/api/classes/types/index.ts create mode 100644 frontend/pages/items.vue diff --git a/backend/app/api/demo.go b/backend/app/api/demo.go index 2bde834..adacfd8 100644 --- a/backend/app/api/demo.go +++ b/backend/app/api/demo.go @@ -21,7 +21,7 @@ func (a *app) SetupDemo() { var ( registration = services.UserRegistration{ - Email: "demo@email.com", + Email: "demo@example.com", Name: "Demo", Password: "demo", } diff --git a/backend/app/api/docs/docs.go b/backend/app/api/docs/docs.go index 346ee39..5663e3f 100644 --- a/backend/app/api/docs/docs.go +++ b/backend/app/api/docs/docs.go @@ -70,26 +70,51 @@ const docTemplate = `{ "Items" ], "summary": "Get All Items", + "parameters": [ + { + "type": "string", + "description": "search string", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "items per page", + "name": "pageSize", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "label Ids", + "name": "labels", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "location Ids", + "name": "locations", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.Results" - }, - { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/repo.ItemSummary" - } - } - } - } - ] + "$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary" } } } @@ -153,7 +178,7 @@ const docTemplate = `{ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -254,7 +279,7 @@ const docTemplate = `{ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -354,7 +379,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -470,7 +495,7 @@ const docTemplate = `{ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -634,7 +659,7 @@ const docTemplate = `{ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -798,7 +823,7 @@ const docTemplate = `{ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -846,7 +871,7 @@ const docTemplate = `{ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -903,7 +928,7 @@ const docTemplate = `{ "summary": "User Logout", "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -922,7 +947,7 @@ const docTemplate = `{ "summary": "User Token Refresh", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -949,7 +974,7 @@ const docTemplate = `{ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -1049,7 +1074,7 @@ const docTemplate = `{ "summary": "Deletes the user account", "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -1070,7 +1095,7 @@ const docTemplate = `{ "summary": "Update the current user's password // TODO:", "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -1488,6 +1513,26 @@ const docTemplate = `{ } } }, + "repo.PaginationResult-repo_ItemSummary": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.ItemSummary" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "repo.UserOut": { "type": "object", "properties": { @@ -1541,9 +1586,7 @@ const docTemplate = `{ "server.Results": { "type": "object", "properties": { - "items": { - "type": "any" - } + "items": {} } }, "server.ValidationError": { diff --git a/backend/app/api/docs/swagger.json b/backend/app/api/docs/swagger.json index 4408d9c..d625365 100644 --- a/backend/app/api/docs/swagger.json +++ b/backend/app/api/docs/swagger.json @@ -62,26 +62,51 @@ "Items" ], "summary": "Get All Items", + "parameters": [ + { + "type": "string", + "description": "search string", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "items per page", + "name": "pageSize", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "label Ids", + "name": "labels", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "location Ids", + "name": "locations", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.Results" - }, - { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/repo.ItemSummary" - } - } - } - } - ] + "$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary" } } } @@ -145,7 +170,7 @@ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -246,7 +271,7 @@ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -346,7 +371,7 @@ ], "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -462,7 +487,7 @@ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -626,7 +651,7 @@ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -790,7 +815,7 @@ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -838,7 +863,7 @@ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -895,7 +920,7 @@ "summary": "User Logout", "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -914,7 +939,7 @@ "summary": "User Token Refresh", "responses": { "200": { - "description": "" + "description": "OK" } } } @@ -941,7 +966,7 @@ ], "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -1041,7 +1066,7 @@ "summary": "Deletes the user account", "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -1062,7 +1087,7 @@ "summary": "Update the current user's password // TODO:", "responses": { "204": { - "description": "" + "description": "No Content" } } } @@ -1480,6 +1505,26 @@ } } }, + "repo.PaginationResult-repo_ItemSummary": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.ItemSummary" + } + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "repo.UserOut": { "type": "object", "properties": { @@ -1533,9 +1578,7 @@ "server.Results": { "type": "object", "properties": { - "items": { - "type": "any" - } + "items": {} } }, "server.ValidationError": { diff --git a/backend/app/api/docs/swagger.yaml b/backend/app/api/docs/swagger.yaml index 876519e..8c69cec 100644 --- a/backend/app/api/docs/swagger.yaml +++ b/backend/app/api/docs/swagger.yaml @@ -275,6 +275,19 @@ definitions: updatedAt: type: string type: object + repo.PaginationResult-repo_ItemSummary: + properties: + items: + items: + $ref: '#/definitions/repo.ItemSummary' + type: array + page: + type: integer + pageSize: + type: integer + total: + type: integer + type: object repo.UserOut: properties: email: @@ -310,8 +323,7 @@ definitions: type: object server.Results: properties: - items: - type: any + items: {} type: object server.ValidationError: properties: @@ -426,20 +438,40 @@ paths: - User /v1/items: get: + parameters: + - description: search string + in: query + name: q + type: string + - description: page number + in: query + name: page + type: integer + - description: items per page + in: query + name: pageSize + type: integer + - collectionFormat: multi + description: label Ids + in: query + items: + type: string + name: labels + type: array + - collectionFormat: multi + description: location Ids + in: query + items: + type: string + name: locations + type: array produces: - application/json responses: "200": description: OK schema: - allOf: - - $ref: '#/definitions/server.Results' - - properties: - items: - items: - $ref: '#/definitions/repo.ItemSummary' - type: array - type: object + $ref: '#/definitions/repo.PaginationResult-repo_ItemSummary' security: - Bearer: [] summary: Get All Items @@ -477,7 +509,7 @@ paths: - application/json responses: "204": - description: "" + description: No Content security: - Bearer: [] summary: deletes a item @@ -583,7 +615,7 @@ paths: type: string responses: "204": - description: "" + description: No Content security: - Bearer: [] summary: retrieves an attachment for an item @@ -658,7 +690,7 @@ paths: - application/octet-stream responses: "200": - description: "" + description: OK security: - Bearer: [] summary: retrieves an attachment for an item @@ -676,7 +708,7 @@ paths: - application/json responses: "204": - description: "" + description: No Content security: - Bearer: [] summary: imports items into the database @@ -735,7 +767,7 @@ paths: - application/json responses: "204": - description: "" + description: No Content security: - Bearer: [] summary: deletes a label @@ -832,7 +864,7 @@ paths: - application/json responses: "204": - description: "" + description: No Content security: - Bearer: [] summary: deletes a location @@ -899,7 +931,7 @@ paths: $ref: '#/definitions/v1.ChangePassword' responses: "204": - description: "" + description: No Content security: - Bearer: [] summary: Updates the users password @@ -935,7 +967,7 @@ paths: post: responses: "204": - description: "" + description: No Content security: - Bearer: [] summary: User Logout @@ -948,7 +980,7 @@ paths: This does not validate that the user still exists within the database. responses: "200": - description: "" + description: OK security: - Bearer: [] summary: User Token Refresh @@ -967,7 +999,7 @@ paths: - application/json responses: "204": - description: "" + description: No Content summary: Get the current user tags: - User @@ -977,7 +1009,7 @@ paths: - application/json responses: "204": - description: "" + description: No Content security: - Bearer: [] summary: Deletes the user account @@ -1032,7 +1064,7 @@ paths: - application/json responses: "204": - description: "" + description: No Content security: - Bearer: [] summary: 'Update the current user''s password // TODO:' diff --git a/backend/app/api/main.go b/backend/app/api/main.go index a1275d5..f963808 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -25,16 +25,16 @@ var ( BuildTime = "now" ) -// @title Go API Templates -// @version 1.0 -// @description This is a simple Rest API Server Template that implements some basic User and Authentication patterns to help you get started and bootstrap your next project!. -// @contact.name Don't -// @license.name MIT -// @BasePath /api -// @securityDefinitions.apikey Bearer -// @in header -// @name Authorization -// @description "Type 'Bearer TOKEN' to correctly set the API Key" +// @title Go API Templates +// @version 1.0 +// @description This is a simple Rest API Server Template that implements some basic User and Authentication patterns to help you get started and bootstrap your next project!. +// @contact.name Don't +// @license.name MIT +// @BasePath /api +// @securityDefinitions.apikey Bearer +// @in header +// @name Authorization +// @description "Type 'Bearer TOKEN' to correctly set the API Key" func main() { cfg, err := config.New() if err != nil { diff --git a/backend/app/api/v1/controller.go b/backend/app/api/v1/controller.go index ee4810f..365feef 100644 --- a/backend/app/api/v1/controller.go +++ b/backend/app/api/v1/controller.go @@ -66,11 +66,11 @@ func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) type ReadyFunc func() bool // HandleBase godoc -// @Summary Retrieves the basic information about the API -// @Tags Base -// @Produce json -// @Success 200 {object} ApiSummary -// @Router /v1/status [GET] +// @Summary Retrieves the basic information about the API +// @Tags Base +// @Produce json +// @Success 200 {object} ApiSummary +// @Router /v1/status [GET] func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { server.Respond(w, http.StatusOK, ApiSummary{ diff --git a/backend/app/api/v1/v1_ctrl_auth.go b/backend/app/api/v1/v1_ctrl_auth.go index 0a4f990..d410a86 100644 --- a/backend/app/api/v1/v1_ctrl_auth.go +++ b/backend/app/api/v1/v1_ctrl_auth.go @@ -23,15 +23,15 @@ type ( ) // HandleAuthLogin godoc -// @Summary User Login -// @Tags Authentication -// @Accept x-www-form-urlencoded -// @Accept application/json -// @Param username formData string false "string" example(admin@admin.com) -// @Param password formData string false "string" example(admin) -// @Produce json -// @Success 200 {object} TokenResponse -// @Router /v1/users/login [POST] +// @Summary User Login +// @Tags Authentication +// @Accept x-www-form-urlencoded +// @Accept application/json +// @Param username formData string false "string" example(admin@admin.com) +// @Param password formData string false "string" example(admin) +// @Produce json +// @Success 200 {object} TokenResponse +// @Router /v1/users/login [POST] func (ctrl *V1Controller) HandleAuthLogin() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { loginForm := &LoginForm{} @@ -80,11 +80,11 @@ func (ctrl *V1Controller) HandleAuthLogin() http.HandlerFunc { } // HandleAuthLogout godoc -// @Summary User Logout -// @Tags Authentication -// @Success 204 -// @Router /v1/users/logout [POST] -// @Security Bearer +// @Summary User Logout +// @Tags Authentication +// @Success 204 +// @Router /v1/users/logout [POST] +// @Security Bearer func (ctrl *V1Controller) HandleAuthLogout() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := services.UseTokenCtx(r.Context()) @@ -106,13 +106,13 @@ func (ctrl *V1Controller) HandleAuthLogout() http.HandlerFunc { } // HandleAuthLogout godoc -// @Summary User Token Refresh -// @Description handleAuthRefresh returns a handler that will issue a new token from an existing token. -// @Description This does not validate that the user still exists within the database. -// @Tags Authentication -// @Success 200 -// @Router /v1/users/refresh [GET] -// @Security Bearer +// @Summary User Token Refresh +// @Description handleAuthRefresh returns a handler that will issue a new token from an existing token. +// @Description This does not validate that the user still exists within the database. +// @Tags Authentication +// @Success 200 +// @Router /v1/users/refresh [GET] +// @Security Bearer func (ctrl *V1Controller) HandleAuthRefresh() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { requestToken := services.UseTokenCtx(r.Context()) diff --git a/backend/app/api/v1/v1_ctrl_group.go b/backend/app/api/v1/v1_ctrl_group.go index faa52a5..8b78fd8 100644 --- a/backend/app/api/v1/v1_ctrl_group.go +++ b/backend/app/api/v1/v1_ctrl_group.go @@ -23,13 +23,13 @@ type ( ) // HandleUserSelf godoc -// @Summary Get the current user -// @Tags User -// @Produce json -// @Param payload body GroupInvitationCreate true "User Data" -// @Success 200 {object} GroupInvitation -// @Router /v1/groups/invitations [Post] -// @Security Bearer +// @Summary Get the current user +// @Tags User +// @Produce json +// @Param payload body GroupInvitationCreate true "User Data" +// @Success 200 {object} GroupInvitation +// @Router /v1/groups/invitations [Post] +// @Security Bearer func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { data := GroupInvitationCreate{} diff --git a/backend/app/api/v1/v1_ctrl_items.go b/backend/app/api/v1/v1_ctrl_items.go index 328ab21..686842d 100644 --- a/backend/app/api/v1/v1_ctrl_items.go +++ b/backend/app/api/v1/v1_ctrl_items.go @@ -3,41 +3,84 @@ package v1 import ( "encoding/csv" "net/http" + "net/url" + "strconv" + "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" ) +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 -// @Produce json -// @Success 200 {object} server.Results{items=[]repo.ItemSummary} -// @Router /v1/items [GET] -// @Security Bearer +// @Summary Get All Items +// @Tags Items +// @Produce json +// @Param q query string false "search string" +// @Param page query int false "page number" +// @Param pageSize query int false "items per page" +// @Param labels query []string false "label Ids" collectionFormat(multi) +// @Param locations query []string false "location Ids" collectionFormat(multi) +// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{} +// @Router /v1/items [GET] +// @Security Bearer func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user := services.UseUserCtx(r.Context()) - items, err := ctrl.svc.Items.GetAll(r.Context(), user.GroupID) + ctx := services.NewContext(r.Context()) + items, err := ctrl.svc.Items.Query(ctx, extractQuery(r)) if err != nil { log.Err(err).Msg("failed to get items") server.RespondServerError(w) return } - server.Respond(w, http.StatusOK, server.Results{Items: items}) + server.Respond(w, http.StatusOK, items) } } // HandleItemsCreate godoc -// @Summary Create a new item -// @Tags Items -// @Produce json -// @Param payload body repo.ItemCreate true "Item Data" -// @Success 200 {object} repo.ItemSummary -// @Router /v1/items [POST] -// @Security Bearer +// @Summary Create a new item +// @Tags Items +// @Produce json +// @Param payload body repo.ItemCreate true "Item Data" +// @Success 200 {object} repo.ItemSummary +// @Router /v1/items [POST] +// @Security Bearer func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { createData := repo.ItemCreate{} @@ -60,13 +103,13 @@ 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 +// @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) @@ -85,13 +128,13 @@ func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc { } // HandleItemGet godocs -// @Summary Gets a item and fields -// @Tags Items -// @Produce json -// @Param id path string true "Item ID" -// @Success 200 {object} repo.ItemOut -// @Router /v1/items/{id} [GET] -// @Security Bearer +// @Summary Gets a item and fields +// @Tags Items +// @Produce json +// @Param id path string true "Item ID" +// @Success 200 {object} repo.ItemOut +// @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) @@ -110,14 +153,14 @@ func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc { } // HandleItemUpdate godocs -// @Summary updates a item -// @Tags Items -// @Produce json -// @Param id path string true "Item ID" -// @Param payload body repo.ItemUpdate true "Item Data" -// @Success 200 {object} repo.ItemOut -// @Router /v1/items/{id} [PUT] -// @Security Bearer +// @Summary updates a item +// @Tags Items +// @Produce json +// @Param id path string true "Item ID" +// @Param payload body repo.ItemUpdate true "Item Data" +// @Success 200 {object} repo.ItemOut +// @Router /v1/items/{id} [PUT] +// @Security Bearer func (ctrl *V1Controller) HandleItemUpdate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body := repo.ItemUpdate{} @@ -143,13 +186,13 @@ func (ctrl *V1Controller) HandleItemUpdate() http.HandlerFunc { } // HandleItemsImport godocs -// @Summary imports items into the database -// @Tags Items -// @Produce json -// @Success 204 -// @Param csv formData file true "Image to upload" -// @Router /v1/items/import [Post] -// @Security Bearer +// @Summary imports items into the database +// @Tags Items +// @Produce json +// @Success 204 +// @Param csv formData file true "Image to upload" +// @Router /v1/items/import [Post] +// @Security Bearer func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/app/api/v1/v1_ctrl_items_attachments.go b/backend/app/api/v1/v1_ctrl_items_attachments.go index c8ff194..a32aaab 100644 --- a/backend/app/api/v1/v1_ctrl_items_attachments.go +++ b/backend/app/api/v1/v1_ctrl_items_attachments.go @@ -21,17 +21,17 @@ type ( ) // HandleItemsImport godocs -// @Summary imports items into the database -// @Tags Items -// @Produce json -// @Param id path string true "Item ID" -// @Param file formData file true "File attachment" -// @Param type formData string true "Type of file" -// @Param name formData string true "name of the file including extension" -// @Success 200 {object} repo.ItemOut -// @Failure 422 {object} []server.ValidationError -// @Router /v1/items/{id}/attachments [POST] -// @Security Bearer +// @Summary imports items into the database +// @Tags Items +// @Produce json +// @Param id path string true "Item ID" +// @Param file formData file true "File attachment" +// @Param type formData string true "Type of file" +// @Param name formData string true "name of the file including extension" +// @Success 200 {object} repo.ItemOut +// @Failure 422 {object} []server.ValidationError +// @Router /v1/items/{id}/attachments [POST] +// @Security Bearer func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { err := r.ParseMultipartForm(ctrl.maxUploadSize << 20) @@ -98,14 +98,14 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { } // HandleItemAttachmentGet godocs -// @Summary retrieves an attachment for an item -// @Tags Items -// @Produce application/octet-stream -// @Param id path string true "Item ID" -// @Param token query string true "Attachment token" -// @Success 200 -// @Router /v1/items/{id}/attachments/download [GET] -// @Security Bearer +// @Summary retrieves an attachment for an item +// @Tags Items +// @Produce application/octet-stream +// @Param id path string true "Item ID" +// @Param token query string true "Attachment token" +// @Success 200 +// @Router /v1/items/{id}/attachments/download [GET] +// @Security Bearer func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := server.GetParam(r, "token", "") @@ -125,39 +125,39 @@ func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc { } // HandleItemAttachmentToken godocs -// @Summary retrieves an attachment for an item -// @Tags Items -// @Produce application/octet-stream -// @Param id path string true "Item ID" -// @Param attachment_id path string true "Attachment ID" -// @Success 200 {object} ItemAttachmentToken -// @Router /v1/items/{id}/attachments/{attachment_id} [GET] -// @Security Bearer +// @Summary retrieves an attachment for an item +// @Tags Items +// @Produce application/octet-stream +// @Param id path string true "Item ID" +// @Param attachment_id path string true "Attachment ID" +// @Success 200 {object} ItemAttachmentToken +// @Router /v1/items/{id}/attachments/{attachment_id} [GET] +// @Security Bearer func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc { return ctrl.handleItemAttachmentsHandler } // HandleItemAttachmentDelete godocs -// @Summary retrieves an attachment for an item -// @Tags Items -// @Param id path string true "Item ID" -// @Param attachment_id path string true "Attachment ID" -// @Success 204 -// @Router /v1/items/{id}/attachments/{attachment_id} [DELETE] -// @Security Bearer +// @Summary retrieves an attachment for an item +// @Tags Items +// @Param id path string true "Item ID" +// @Param attachment_id path string true "Attachment ID" +// @Success 204 +// @Router /v1/items/{id}/attachments/{attachment_id} [DELETE] +// @Security Bearer func (ctrl *V1Controller) HandleItemAttachmentDelete() http.HandlerFunc { return ctrl.handleItemAttachmentsHandler } // HandleItemAttachmentUpdate godocs -// @Summary retrieves an attachment for an item -// @Tags Items -// @Param id path string true "Item ID" -// @Param attachment_id path string true "Attachment ID" -// @Param payload body repo.ItemAttachmentUpdate true "Attachment Update" -// @Success 200 {object} repo.ItemOut -// @Router /v1/items/{id}/attachments/{attachment_id} [PUT] -// @Security Bearer +// @Summary retrieves an attachment for an item +// @Tags Items +// @Param id path string true "Item ID" +// @Param attachment_id path string true "Attachment ID" +// @Param payload body repo.ItemAttachmentUpdate true "Attachment Update" +// @Success 200 {object} repo.ItemOut +// @Router /v1/items/{id}/attachments/{attachment_id} [PUT] +// @Security Bearer func (ctrl *V1Controller) HandleItemAttachmentUpdate() http.HandlerFunc { return ctrl.handleItemAttachmentsHandler } diff --git a/backend/app/api/v1/v1_ctrl_labels.go b/backend/app/api/v1/v1_ctrl_labels.go index f4811e3..8400748 100644 --- a/backend/app/api/v1/v1_ctrl_labels.go +++ b/backend/app/api/v1/v1_ctrl_labels.go @@ -11,12 +11,12 @@ import ( ) // HandleLabelsGetAll godoc -// @Summary Get All Labels -// @Tags Labels -// @Produce json -// @Success 200 {object} server.Results{items=[]repo.LabelOut} -// @Router /v1/labels [GET] -// @Security Bearer +// @Summary Get All Labels +// @Tags Labels +// @Produce json +// @Success 200 {object} server.Results{items=[]repo.LabelOut} +// @Router /v1/labels [GET] +// @Security Bearer func (ctrl *V1Controller) HandleLabelsGetAll() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := services.UseUserCtx(r.Context()) @@ -31,13 +31,13 @@ func (ctrl *V1Controller) HandleLabelsGetAll() http.HandlerFunc { } // HandleLabelsCreate godoc -// @Summary Create a new label -// @Tags Labels -// @Produce json -// @Param payload body repo.LabelCreate true "Label Data" -// @Success 200 {object} repo.LabelSummary -// @Router /v1/labels [POST] -// @Security Bearer +// @Summary Create a new label +// @Tags Labels +// @Produce json +// @Param payload body repo.LabelCreate true "Label Data" +// @Success 200 {object} repo.LabelSummary +// @Router /v1/labels [POST] +// @Security Bearer func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { createData := repo.LabelCreate{} @@ -61,13 +61,13 @@ func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc { } // HandleLabelDelete godocs -// @Summary deletes a label -// @Tags Labels -// @Produce json -// @Param id path string true "Label ID" -// @Success 204 -// @Router /v1/labels/{id} [DELETE] -// @Security Bearer +// @Summary deletes a label +// @Tags Labels +// @Produce json +// @Param id path string true "Label ID" +// @Success 204 +// @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) @@ -86,13 +86,13 @@ func (ctrl *V1Controller) HandleLabelDelete() http.HandlerFunc { } // HandleLabelGet godocs -// @Summary Gets a label and fields -// @Tags Labels -// @Produce json -// @Param id path string true "Label ID" -// @Success 200 {object} repo.LabelOut -// @Router /v1/labels/{id} [GET] -// @Security Bearer +// @Summary Gets a label and fields +// @Tags Labels +// @Produce json +// @Param id path string true "Label ID" +// @Success 200 {object} repo.LabelOut +// @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) @@ -118,13 +118,13 @@ func (ctrl *V1Controller) HandleLabelGet() http.HandlerFunc { } // HandleLabelUpdate godocs -// @Summary updates a label -// @Tags Labels -// @Produce json -// @Param id path string true "Label ID" -// @Success 200 {object} repo.LabelOut -// @Router /v1/labels/{id} [PUT] -// @Security Bearer +// @Summary updates a label +// @Tags Labels +// @Produce json +// @Param id path string true "Label ID" +// @Success 200 {object} repo.LabelOut +// @Router /v1/labels/{id} [PUT] +// @Security Bearer func (ctrl *V1Controller) HandleLabelUpdate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body := repo.LabelUpdate{} diff --git a/backend/app/api/v1/v1_ctrl_locations.go b/backend/app/api/v1/v1_ctrl_locations.go index 5dabc00..3505ff4 100644 --- a/backend/app/api/v1/v1_ctrl_locations.go +++ b/backend/app/api/v1/v1_ctrl_locations.go @@ -11,12 +11,12 @@ import ( ) // HandleLocationGetAll godoc -// @Summary Get All Locations -// @Tags Locations -// @Produce json -// @Success 200 {object} server.Results{items=[]repo.LocationOutCount} -// @Router /v1/locations [GET] -// @Security Bearer +// @Summary Get All Locations +// @Tags Locations +// @Produce json +// @Success 200 {object} server.Results{items=[]repo.LocationOutCount} +// @Router /v1/locations [GET] +// @Security Bearer func (ctrl *V1Controller) HandleLocationGetAll() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := services.UseUserCtx(r.Context()) @@ -32,13 +32,13 @@ func (ctrl *V1Controller) HandleLocationGetAll() http.HandlerFunc { } // HandleLocationCreate godoc -// @Summary Create a new location -// @Tags Locations -// @Produce json -// @Param payload body repo.LocationCreate true "Location Data" -// @Success 200 {object} repo.LocationSummary -// @Router /v1/locations [POST] -// @Security Bearer +// @Summary Create a new location +// @Tags Locations +// @Produce json +// @Param payload body repo.LocationCreate true "Location Data" +// @Success 200 {object} repo.LocationSummary +// @Router /v1/locations [POST] +// @Security Bearer func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { createData := repo.LocationCreate{} @@ -61,13 +61,13 @@ func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc { } // HandleLocationDelete godocs -// @Summary deletes a location -// @Tags Locations -// @Produce json -// @Param id path string true "Location ID" -// @Success 204 -// @Router /v1/locations/{id} [DELETE] -// @Security Bearer +// @Summary deletes a location +// @Tags Locations +// @Produce json +// @Param id path string true "Location ID" +// @Success 204 +// @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) @@ -86,13 +86,13 @@ func (ctrl *V1Controller) HandleLocationDelete() http.HandlerFunc { } // HandleLocationGet godocs -// @Summary Gets a location and fields -// @Tags Locations -// @Produce json -// @Param id path string true "Location ID" -// @Success 200 {object} repo.LocationOut -// @Router /v1/locations/{id} [GET] -// @Security Bearer +// @Summary Gets a location and fields +// @Tags Locations +// @Produce json +// @Param id path string true "Location ID" +// @Success 200 {object} repo.LocationOut +// @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) @@ -123,13 +123,13 @@ func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc { } // HandleLocationUpdate godocs -// @Summary updates a location -// @Tags Locations -// @Produce json -// @Param id path string true "Location ID" -// @Success 200 {object} repo.LocationOut -// @Router /v1/locations/{id} [PUT] -// @Security Bearer +// @Summary updates a location +// @Tags Locations +// @Produce json +// @Param id path string true "Location ID" +// @Success 200 {object} repo.LocationOut +// @Router /v1/locations/{id} [PUT] +// @Security Bearer func (ctrl *V1Controller) HandleLocationUpdate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body := repo.LocationUpdate{} diff --git a/backend/app/api/v1/v1_ctrl_user.go b/backend/app/api/v1/v1_ctrl_user.go index bb5f617..10fdbb1 100644 --- a/backend/app/api/v1/v1_ctrl_user.go +++ b/backend/app/api/v1/v1_ctrl_user.go @@ -11,12 +11,12 @@ import ( ) // HandleUserSelf godoc -// @Summary Get the current user -// @Tags User -// @Produce json -// @Param payload body services.UserRegistration true "User Data" -// @Success 204 -// @Router /v1/users/register [Post] +// @Summary Get the current user +// @Tags User +// @Produce json +// @Param payload body services.UserRegistration true "User Data" +// @Success 204 +// @Router /v1/users/register [Post] func (ctrl *V1Controller) HandleUserRegistration() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { regData := services.UserRegistration{} @@ -39,12 +39,12 @@ func (ctrl *V1Controller) HandleUserRegistration() http.HandlerFunc { } // HandleUserSelf godoc -// @Summary Get the current user -// @Tags User -// @Produce json -// @Success 200 {object} server.Result{item=repo.UserOut} -// @Router /v1/users/self [GET] -// @Security Bearer +// @Summary Get the current user +// @Tags User +// @Produce json +// @Success 200 {object} server.Result{item=repo.UserOut} +// @Router /v1/users/self [GET] +// @Security Bearer func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := services.UseTokenCtx(r.Context()) @@ -60,13 +60,13 @@ func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc { } // HandleUserSelfUpdate godoc -// @Summary Update the current user -// @Tags User -// @Produce json -// @Param payload body repo.UserUpdate true "User Data" -// @Success 200 {object} server.Result{item=repo.UserUpdate} -// @Router /v1/users/self [PUT] -// @Security Bearer +// @Summary Update the current user +// @Tags User +// @Produce json +// @Param payload body repo.UserUpdate true "User Data" +// @Success 200 {object} server.Result{item=repo.UserUpdate} +// @Router /v1/users/self [PUT] +// @Security Bearer func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { updateData := repo.UserUpdate{} @@ -90,24 +90,24 @@ 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 +// @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 -// @Produce json -// @Success 204 -// @Router /v1/users/self [DELETE] -// @Security Bearer +// @Summary Deletes the user account +// @Tags User +// @Produce json +// @Success 204 +// @Router /v1/users/self [DELETE] +// @Security Bearer func (ctrl *V1Controller) HandleUserSelfDelete() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { actor := services.UseUserCtx(r.Context()) @@ -128,12 +128,12 @@ type ( ) // HandleUserSelfChangePassword godoc -// @Summary Updates the users password -// @Tags User -// @Success 204 -// @Param payload body ChangePassword true "Password Payload" -// @Router /v1/users/change-password [PUT] -// @Security Bearer +// @Summary Updates the users password +// @Tags User +// @Success 204 +// @Param payload body ChangePassword true "Password Payload" +// @Router /v1/users/change-password [PUT] +// @Security Bearer func (ctrl *V1Controller) HandleUserSelfChangePassword() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if ctrl.isDemo { diff --git a/backend/internal/repo/pagination.go b/backend/internal/repo/pagination.go new file mode 100644 index 0000000..d8878d0 --- /dev/null +++ b/backend/internal/repo/pagination.go @@ -0,0 +1,12 @@ +package repo + +type PaginationResult[T any] struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` + Items []T `json:"items"` +} + +func calculateOffset(page, pageSize int) int { + return (page - 1) * pageSize +} diff --git a/backend/internal/repo/repo_items.go b/backend/internal/repo/repo_items.go index e6375f7..7dd81ae 100644 --- a/backend/internal/repo/repo_items.go +++ b/backend/internal/repo/repo_items.go @@ -8,6 +8,8 @@ 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/label" + "github.com/hay-kot/homebox/backend/ent/location" "github.com/hay-kot/homebox/backend/ent/predicate" ) @@ -16,6 +18,14 @@ type ItemsRepository struct { } type ( + ItemQuery struct { + Page int + PageSize int + Search string `json:"search"` + LocationIDs []uuid.UUID `json:"locationIds"` + LabelIDs []uuid.UUID `json:"labelIds"` + } + ItemCreate struct { ImportRef string `json:"-"` Name string `json:"name"` @@ -206,6 +216,64 @@ func (e *ItemsRepository) GetOneByGroup(ctx context.Context, gid, id uuid.UUID) return e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid))) } +// QueryByGroup returns a list of items that belong to a specific group based on the provided query. +func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q ItemQuery) (PaginationResult[ItemSummary], error) { + qb := e.db.Item.Query().Where(item.HasGroupWith(group.ID(gid))) + + if len(q.LabelIDs) > 0 { + labels := make([]predicate.Item, 0, len(q.LabelIDs)) + for _, l := range q.LabelIDs { + labels = append(labels, item.HasLabelWith(label.ID(l))) + } + qb = qb.Where(item.Or(labels...)) + } + + if len(q.LocationIDs) > 0 { + locations := make([]predicate.Item, 0, len(q.LocationIDs)) + for _, l := range q.LocationIDs { + locations = append(locations, item.HasLocationWith(location.ID(l))) + } + qb = qb.Where(item.Or(locations...)) + } + + if q.Search != "" { + qb.Where( + item.Or( + item.NameContainsFold(q.Search), + item.DescriptionContainsFold(q.Search), + ), + ) + } + + if q.Page != -1 || q.PageSize != -1 { + qb = qb. + Offset(calculateOffset(q.Page, q.PageSize)). + Limit(q.PageSize) + } + + items, err := mapItemsSummaryErr( + qb.WithLabel(). + WithLocation(). + All(ctx), + ) + if err != nil { + return PaginationResult[ItemSummary]{}, err + } + + count, err := qb.Count(ctx) + if err != nil { + return PaginationResult[ItemSummary]{}, err + } + + return PaginationResult[ItemSummary]{ + Page: q.Page, + PageSize: q.PageSize, + Total: count, + Items: items, + }, nil + +} + // GetAll returns all the items in the database with the Labels and Locations eager loaded. func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSummary, error) { return mapItemsSummaryErr(e.db.Item.Query(). diff --git a/backend/internal/services/service_items.go b/backend/internal/services/service_items.go index bc1a994..e944e14 100644 --- a/backend/internal/services/service_items.go +++ b/backend/internal/services/service_items.go @@ -28,6 +28,10 @@ func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID) return svc.repo.Items.GetOneByGroup(ctx, gid, id) } +func (svc *ItemService) Query(ctx Context, q repo.ItemQuery) (repo.PaginationResult[repo.ItemSummary], error) { + return svc.repo.Items.QueryByGroup(ctx, ctx.GID, q) +} + func (svc *ItemService) GetAll(ctx context.Context, gid uuid.UUID) ([]repo.ItemSummary, error) { return svc.repo.Items.GetAll(ctx, gid) } diff --git a/backend/pkgs/faker/random.go b/backend/pkgs/faker/random.go index 05428fa..67c7114 100644 --- a/backend/pkgs/faker/random.go +++ b/backend/pkgs/faker/random.go @@ -33,7 +33,7 @@ func (f *Faker) Path() string { } func (f *Faker) Email() string { - return f.Str(10) + "@email.com" + return f.Str(10) + "@example.com" } func (f *Faker) Bool() bool { diff --git a/frontend/components/App/Header.vue b/frontend/components/App/Header.vue index 9c6b552..1561c87 100644 --- a/frontend/components/App/Header.vue +++ b/frontend/components/App/Header.vue @@ -18,6 +18,10 @@ name: "Home", href: "/home", }, + { + name: "Items", + href: "/items", + }, { name: "Logout", action: logout, diff --git a/frontend/components/Form/Multiselect.vue b/frontend/components/Form/Multiselect.vue index bf0e0cc..52cf2c5 100644 --- a/frontend/components/Form/Multiselect.vue +++ b/frontend/components/Form/Multiselect.vue @@ -11,7 +11,8 @@