diff --git a/backend/app/api/handlers/v1/v1_ctrl_locations.go b/backend/app/api/handlers/v1/v1_ctrl_locations.go index 12e3c29..ee2785a 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_locations.go +++ b/backend/app/api/handlers/v1/v1_ctrl_locations.go @@ -11,6 +11,39 @@ import ( "github.com/rs/zerolog/log" ) +// HandleLocationTreeQuery godoc +// @Summary Get All Locations +// @Tags Locations +// @Produce json +// @Param withItems query bool false "include items in response tree" +// @Success 200 {object} server.Results{items=[]repo.TreeItem} +// @Router /v1/locations/tree [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleLocationTreeQuery() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + user := services.UseUserCtx(r.Context()) + + q := r.URL.Query() + + withItems := queryBool(q.Get("withItems")) + + locTree, err := ctrl.repo.Locations.Tree( + r.Context(), + user.GroupID, + repo.TreeQuery{ + WithItems: withItems, + }, + ) + + if err != nil { + log.Err(err).Msg("failed to get locations tree") + return validate.NewRequestError(err, http.StatusInternalServerError) + } + + return server.Respond(w, http.StatusOK, server.Results{Items: locTree}) + } +} + // HandleLocationGetAll godoc // @Summary Get All Locations // @Tags Locations diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index b5ae495..3475e7a 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -91,6 +91,7 @@ func (a *app) mountRoutes(repos *repo.AllRepos) { a.server.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll(), userMW...) a.server.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate(), userMW...) + a.server.Get(v1Base("/locations/tree"), v1Ctrl.HandleLocationTreeQuery(), userMW...) a.server.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet(), userMW...) a.server.Put(v1Base("/locations/{id}"), v1Ctrl.HandleLocationUpdate(), userMW...) a.server.Delete(v1Base("/locations/{id}"), v1Ctrl.HandleLocationDelete(), userMW...) diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 4aa7410..e8c89a1 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -1045,6 +1045,53 @@ const docTemplate = `{ } } }, + "/v1/locations/tree": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Locations" + ], + "summary": "Get All Locations", + "parameters": [ + { + "type": "boolean", + "description": "include items in response tree", + "name": "withItems", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/server.Results" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.TreeItem" + } + } + } + } + ] + } + } + } + } + }, "/v1/locations/{id}": { "get": { "security": [ @@ -1906,6 +1953,10 @@ const docTemplate = `{ }, "name": { "type": "string" + }, + "parentId": { + "type": "string", + "x-nullable": true } } }, @@ -2116,6 +2167,26 @@ const docTemplate = `{ } } }, + "repo.TreeItem": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.TreeItem" + } + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "repo.UserOut": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 6e2c0cf..499f83f 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -1037,6 +1037,53 @@ } } }, + "/v1/locations/tree": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Locations" + ], + "summary": "Get All Locations", + "parameters": [ + { + "type": "boolean", + "description": "include items in response tree", + "name": "withItems", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/server.Results" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.TreeItem" + } + } + } + } + ] + } + } + } + } + }, "/v1/locations/{id}": { "get": { "security": [ @@ -1898,6 +1945,10 @@ }, "name": { "type": "string" + }, + "parentId": { + "type": "string", + "x-nullable": true } } }, @@ -2108,6 +2159,26 @@ } } }, + "repo.TreeItem": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.TreeItem" + } + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "repo.UserOut": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index a6ef340..1781f32 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -322,6 +322,9 @@ definitions: type: string name: type: string + parentId: + type: string + x-nullable: true type: object repo.LocationOut: properties: @@ -459,6 +462,19 @@ definitions: total: type: number type: object + repo.TreeItem: + properties: + children: + items: + $ref: '#/definitions/repo.TreeItem' + type: array + id: + type: string + name: + type: string + type: + type: string + type: object repo.UserOut: properties: email: @@ -1301,6 +1317,32 @@ paths: summary: updates a location tags: - Locations + /v1/locations/tree: + get: + parameters: + - description: include items in response tree + in: query + name: withItems + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/server.Results' + - properties: + items: + items: + $ref: '#/definitions/repo.TreeItem' + type: array + type: object + security: + - Bearer: [] + summary: Get All Locations + tags: + - Locations /v1/qrcode: get: parameters: diff --git a/backend/internal/data/ent/group/group.go b/backend/internal/data/ent/group/group.go index 379a114..28cf69d 100644 --- a/backend/internal/data/ent/group/group.go +++ b/backend/internal/data/ent/group/group.go @@ -131,6 +131,7 @@ const ( CurrencyDkk Currency = "dkk" CurrencyInr Currency = "inr" CurrencyRmb Currency = "rmb" + CurrencyBgn Currency = "bgn" ) func (c Currency) String() string { @@ -140,7 +141,7 @@ func (c Currency) String() string { // CurrencyValidator is a validator for the "currency" field enum values. It is called by the builders before save. func CurrencyValidator(c Currency) error { switch c { - case CurrencyUsd, CurrencyEur, CurrencyGbp, CurrencyJpy, CurrencyZar, CurrencyAud, CurrencyNok, CurrencySek, CurrencyDkk, CurrencyInr, CurrencyRmb: + case CurrencyUsd, CurrencyEur, CurrencyGbp, CurrencyJpy, CurrencyZar, CurrencyAud, CurrencyNok, CurrencySek, CurrencyDkk, CurrencyInr, CurrencyRmb, CurrencyBgn: return nil default: return fmt.Errorf("group: invalid enum value for currency field: %q", c) diff --git a/backend/internal/data/ent/migrate/schema.go b/backend/internal/data/ent/migrate/schema.go index c700d34..a5349e2 100644 --- a/backend/internal/data/ent/migrate/schema.go +++ b/backend/internal/data/ent/migrate/schema.go @@ -116,7 +116,7 @@ var ( {Name: "created_at", Type: field.TypeTime}, {Name: "updated_at", Type: field.TypeTime}, {Name: "name", Type: field.TypeString, Size: 255}, - {Name: "currency", Type: field.TypeEnum, Enums: []string{"usd", "eur", "gbp", "jpy", "zar", "aud", "nok", "sek", "dkk", "inr", "rmb"}, Default: "usd"}, + {Name: "currency", Type: field.TypeEnum, Enums: []string{"usd", "eur", "gbp", "jpy", "zar", "aud", "nok", "sek", "dkk", "inr", "rmb", "bgn"}, Default: "usd"}, } // GroupsTable holds the schema information for the "groups" table. GroupsTable = &schema.Table{ diff --git a/backend/internal/data/ent/schema/group.go b/backend/internal/data/ent/schema/group.go index a2011d8..2c0a5fe 100644 --- a/backend/internal/data/ent/schema/group.go +++ b/backend/internal/data/ent/schema/group.go @@ -27,7 +27,7 @@ func (Group) Fields() []ent.Field { NotEmpty(), field.Enum("currency"). Default("usd"). - Values("usd", "eur", "gbp", "jpy", "zar", "aud", "nok", "sek", "dkk", "inr", "rmb"), + Values("usd", "eur", "gbp", "jpy", "zar", "aud", "nok", "sek", "dkk", "inr", "rmb", "bgn"), } } diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index f5ddb4e..d6faccd 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -356,7 +356,6 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite } - // QueryByAssetID returns items by asset ID. If the item does not exist, an error is returned. func (e *ItemsRepository) QueryByAssetID(ctx context.Context, gid uuid.UUID, assetID AssetID, page int, pageSize int) (PaginationResult[ItemSummary], error) { qb := e.db.Item.Query().Where( @@ -372,7 +371,7 @@ func (e *ItemsRepository) QueryByAssetID(ctx context.Context, gid uuid.UUID, ass pageSize = -1 } - items, err := mapItemsSummaryErr( + items, err := mapItemsSummaryErr( qb.Order(ent.Asc(item.FieldName)). WithLabel(). WithLocation(). diff --git a/backend/internal/data/repo/repo_items_test.go b/backend/internal/data/repo/repo_items_test.go index 4b958b0..6d361ba 100644 --- a/backend/internal/data/repo/repo_items_test.go +++ b/backend/internal/data/repo/repo_items_test.go @@ -36,6 +36,8 @@ func useItems(t *testing.T, len int) []ItemOut { for _, item := range items { _ = tRepos.Items.Delete(context.Background(), item.ID) } + + _ = tRepos.Locations.Delete(context.Background(), location.ID) }) return items diff --git a/backend/internal/data/repo/repo_locations.go b/backend/internal/data/repo/repo_locations.go index 641b3e3..a5ca126 100644 --- a/backend/internal/data/repo/repo_locations.go +++ b/backend/internal/data/repo/repo_locations.go @@ -19,8 +19,9 @@ type LocationRepository struct { type ( LocationCreate struct { - Name string `json:"name"` - Description string `json:"description"` + Name string `json:"name"` + ParentID uuid.UUID `json:"parentId" extensions:"x-nullable"` + Description string `json:"description"` } LocationUpdate struct { @@ -152,7 +153,9 @@ func (r *LocationRepository) getOne(ctx context.Context, where ...predicate.Loca Where(where...). WithGroup(). WithItems(func(iq *ent.ItemQuery) { - iq.Where(item.Archived(false)).WithLabel() + iq.Where(item.Archived(false)). + Order(ent.Asc(item.FieldName)). + WithLabel() }). WithParent(). WithChildren(). @@ -168,11 +171,16 @@ func (r *LocationRepository) GetOneByGroup(ctx context.Context, GID, ID uuid.UUI } func (r *LocationRepository) Create(ctx context.Context, GID uuid.UUID, data LocationCreate) (LocationOut, error) { - location, err := r.db.Location.Create(). + q := r.db.Location.Create(). SetName(data.Name). SetDescription(data.Description). - SetGroupID(GID). - Save(ctx) + SetGroupID(GID) + + if data.ParentID != uuid.Nil { + q.SetParentID(data.ParentID) + } + + location, err := q.Save(ctx) if err != nil { return LocationOut{}, err @@ -218,3 +226,129 @@ func (r *LocationRepository) DeleteByGroup(ctx context.Context, GID, ID uuid.UUI _, err := r.db.Location.Delete().Where(location.ID(ID), location.HasGroupWith(group.ID(GID))).Exec(ctx) return err } + +type TreeItem struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Children []*TreeItem `json:"children"` +} + +type FlatTreeItem struct { + ID uuid.UUID + Name string + Type string + ParentID uuid.UUID + Level int +} + +type TreeQuery struct { + WithItems bool `json:"withItems"` +} + +func (lr *LocationRepository) Tree(ctx context.Context, GID uuid.UUID, tq TreeQuery) ([]TreeItem, error) { + query := ` + WITH recursive location_tree(id, NAME, location_children, level, node_type) AS + ( + SELECT id, + NAME, + location_children, + 0 AS level, + 'location' AS node_type + FROM locations + WHERE location_children IS NULL + AND group_locations = ? + + UNION ALL + SELECT c.id, + c.NAME, + c.location_children, + level + 1, + 'location' AS node_type + FROM locations c + JOIN location_tree p + ON c.location_children = p.id + WHERE level < 10 -- prevent infinite loop & excessive recursion + + {{ WITH_ITEMS }} + ) + SELECT id, + NAME, + level, + location_children, + node_type + FROM location_tree + ORDER BY level, + node_type DESC, -- sort locations before items + NAME;` + + if tq.WithItems { + itemQuery := ` + UNION ALL + SELECT i.id, + i.name, + location_items as location_children, + level + 1, + 'item' AS node_type + FROM items i + JOIN location_tree p + ON i.location_items = p.id + WHERE level < 10 -- prevent infinite loop & excessive recursion` + query = strings.ReplaceAll(query, "{{ WITH_ITEMS }}", itemQuery) + } else { + query = strings.ReplaceAll(query, "{{ WITH_ITEMS }}", "") + } + + rows, err := lr.db.Sql().QueryContext(ctx, query, GID) + if err != nil { + return nil, err + } + defer rows.Close() + + var locations []FlatTreeItem + for rows.Next() { + var location FlatTreeItem + if err := rows.Scan(&location.ID, &location.Name, &location.Level, &location.ParentID, &location.Type); err != nil { + return nil, err + } + locations = append(locations, location) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return ConvertLocationsToTree(locations), nil +} + +func ConvertLocationsToTree(locations []FlatTreeItem) []TreeItem { + var locationMap = make(map[uuid.UUID]*TreeItem, len(locations)) + + var rootIds []uuid.UUID + + for _, location := range locations { + loc := &TreeItem{ + ID: location.ID, + Name: location.Name, + Type: location.Type, + Children: []*TreeItem{}, + } + + locationMap[location.ID] = loc + if location.ParentID != uuid.Nil { + parent, ok := locationMap[location.ParentID] + if ok { + parent.Children = append(parent.Children, loc) + } + } else { + rootIds = append(rootIds, location.ID) + } + } + + roots := make([]TreeItem, 0, len(rootIds)) + for _, id := range rootIds { + roots = append(roots, *locationMap[id]) + } + + return roots +} diff --git a/backend/internal/data/repo/repo_locations_test.go b/backend/internal/data/repo/repo_locations_test.go index 5085d3b..1ef2295 100644 --- a/backend/internal/data/repo/repo_locations_test.go +++ b/backend/internal/data/repo/repo_locations_test.go @@ -2,8 +2,11 @@ package repo import ( "context" + "encoding/json" "testing" + "github.com/google/uuid" + "github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/stretchr/testify/assert" ) @@ -14,6 +17,30 @@ func locationFactory() LocationCreate { } } +func useLocations(t *testing.T, len int) []LocationOut { + t.Helper() + + out := make([]LocationOut, len) + + for i := 0; i < len; i++ { + loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) + assert.NoError(t, err) + out[i] = loc + } + + t.Cleanup(func() { + for _, loc := range out { + err := tRepos.Locations.Delete(context.Background(), loc.ID) + + if err != nil { + assert.True(t, ent.IsNotFound(err)) + } + } + }) + + return out +} + func TestLocationRepository_Get(t *testing.T) { loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) assert.NoError(t, err) @@ -29,13 +56,9 @@ func TestLocationRepository_Get(t *testing.T) { func TestLocationRepositoryGetAllWithCount(t *testing.T) { ctx := context.Background() - result, err := tRepos.Locations.Create(ctx, tGroup.ID, LocationCreate{ - Name: fk.Str(10), - Description: fk.Str(100), - }) - assert.NoError(t, err) + result := useLocations(t, 1)[0] - _, err = tRepos.Items.Create(ctx, tGroup.ID, ItemCreate{ + _, err := tRepos.Items.Create(ctx, tGroup.ID, ItemCreate{ Name: fk.Str(10), Description: fk.Str(100), LocationID: result.ID, @@ -55,8 +78,7 @@ func TestLocationRepositoryGetAllWithCount(t *testing.T) { } func TestLocationRepository_Create(t *testing.T) { - loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) - assert.NoError(t, err) + loc := useLocations(t, 1)[0] // Get by ID foundLoc, err := tRepos.Locations.Get(context.Background(), loc.ID) @@ -68,8 +90,7 @@ func TestLocationRepository_Create(t *testing.T) { } func TestLocationRepository_Update(t *testing.T) { - loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) - assert.NoError(t, err) + loc := useLocations(t, 1)[0] updateData := LocationUpdate{ ID: loc.ID, @@ -92,12 +113,154 @@ func TestLocationRepository_Update(t *testing.T) { } func TestLocationRepository_Delete(t *testing.T) { - loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) - assert.NoError(t, err) + loc := useLocations(t, 1)[0] - err = tRepos.Locations.Delete(context.Background(), loc.ID) + err := tRepos.Locations.Delete(context.Background(), loc.ID) assert.NoError(t, err) _, err = tRepos.Locations.Get(context.Background(), loc.ID) assert.Error(t, err) } + +func TestItemRepository_TreeQuery(t *testing.T) { + locs := useLocations(t, 3) + + // Set relations + _, err := tRepos.Locations.UpdateOneByGroup(context.Background(), tGroup.ID, locs[0].ID, LocationUpdate{ + ID: locs[0].ID, + ParentID: locs[1].ID, + Name: locs[0].Name, + Description: locs[0].Description, + }) + assert.NoError(t, err) + + locations, err := tRepos.Locations.Tree(context.Background(), tGroup.ID, TreeQuery{WithItems: true}) + + assert.NoError(t, err) + + assert.Equal(t, 2, len(locations)) + + // Check roots + for _, loc := range locations { + if loc.ID == locs[1].ID { + assert.Equal(t, 1, len(loc.Children)) + } + } +} + +func TestConvertLocationsToTree(t *testing.T) { + uuid1, uuid2, uuid3, uuid4 := uuid.New(), uuid.New(), uuid.New(), uuid.New() + + testCases := []struct { + name string + locations []FlatTreeItem + expected []TreeItem + }{ + { + name: "Convert locations to tree", + locations: []FlatTreeItem{ + { + ID: uuid1, + Name: "Root1", + ParentID: uuid.Nil, + Level: 0, + }, + { + ID: uuid2, + Name: "Child1", + ParentID: uuid1, + Level: 1, + }, + { + ID: uuid3, + Name: "Child2", + ParentID: uuid1, + Level: 1, + }, + }, + expected: []TreeItem{ + { + ID: uuid1, + Name: "Root1", + Children: []*TreeItem{ + { + ID: uuid2, + Name: "Child1", + Children: []*TreeItem{}, + }, + { + ID: uuid3, + Name: "Child2", + Children: []*TreeItem{}, + }, + }, + }, + }, + }, + { + name: "Convert locations to tree with deeply nested children", + locations: []FlatTreeItem{ + { + ID: uuid1, + Name: "Root1", + ParentID: uuid.Nil, + Level: 0, + }, + { + ID: uuid2, + Name: "Child1", + ParentID: uuid1, + Level: 1, + }, + { + ID: uuid3, + Name: "Child2", + ParentID: uuid2, + Level: 2, + }, + { + ID: uuid4, + Name: "Child3", + ParentID: uuid3, + Level: 3, + }, + }, + expected: []TreeItem{ + { + ID: uuid1, + Name: "Root1", + Children: []*TreeItem{ + { + ID: uuid2, + Name: "Child1", + Children: []*TreeItem{ + { + ID: uuid3, + Name: "Child2", + Children: []*TreeItem{ + { + ID: uuid4, + Name: "Child3", + Children: []*TreeItem{}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := ConvertLocationsToTree(tc.locations) + + // Compare JSON strings + expected, _ := json.Marshal(tc.expected) + got, _ := json.Marshal(result) + assert.Equal(t, string(expected), string(got)) + }) + } +} diff --git a/frontend/components/Form/Autocomplete.vue b/frontend/components/Form/Autocomplete.vue index 4a43dff..b2e2d44 100644 --- a/frontend/components/Form/Autocomplete.vue +++ b/frontend/components/Form/Autocomplete.vue @@ -6,7 +6,7 @@