diff --git a/cmd/database.go b/cmd/database.go index bc2651dc..911a6806 100644 --- a/cmd/database.go +++ b/cmd/database.go @@ -101,6 +101,14 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error { log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully") + err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTagGroup)) + + if err != nil { + return err + } + + log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag group table maintained successfully") + err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTag)) if err != nil { diff --git a/cmd/webserver.go b/cmd/webserver.go index 307ecdfa..c43c8a45 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -418,6 +418,14 @@ func startWebServer(c *core.CliContext) error { apiV1Route.POST("/transaction/categories/move.json", bindApi(api.TransactionCategories.CategoryMoveHandler)) apiV1Route.POST("/transaction/categories/delete.json", bindApi(api.TransactionCategories.CategoryDeleteHandler)) + // Transaction Tag Groups + apiV1Route.GET("/transaction/tags/groups/list.json", bindApi(api.TransactionTagGroups.TagGroupListHandler)) + apiV1Route.GET("/transaction/tags/groups/get.json", bindApi(api.TransactionTagGroups.TagGroupGetHandler)) + apiV1Route.POST("/transaction/tags/groups/add.json", bindApi(api.TransactionTagGroups.TagGroupCreateHandler)) + apiV1Route.POST("/transaction/tags/groups/modify.json", bindApi(api.TransactionTagGroups.TagGroupModifyHandler)) + apiV1Route.POST("/transaction/tags/groups/move.json", bindApi(api.TransactionTagGroups.TagGroupMoveHandler)) + apiV1Route.POST("/transaction/tags/groups/delete.json", bindApi(api.TransactionTagGroups.TagGroupDeleteHandler)) + // Transaction Tags apiV1Route.GET("/transaction/tags/list.json", bindApi(api.TransactionTags.TagListHandler)) apiV1Route.GET("/transaction/tags/get.json", bindApi(api.TransactionTags.TagGetHandler)) diff --git a/pkg/api/data_managements.go b/pkg/api/data_managements.go index 4556af9f..254af6f8 100644 --- a/pkg/api/data_managements.go +++ b/pkg/api/data_managements.go @@ -28,6 +28,7 @@ type DataManagementsApi struct { transactions *services.TransactionService categories *services.TransactionCategoryService tags *services.TransactionTagService + tagGroups *services.TransactionTagGroupService pictures *services.TransactionPictureService templates *services.TransactionTemplateService userCustomExchangeRates *services.UserCustomExchangeRatesService @@ -46,6 +47,7 @@ var ( transactions: services.Transactions, categories: services.TransactionCategories, tags: services.TransactionTags, + tagGroups: services.TransactionTagGroups, pictures: services.TransactionPictures, templates: services.TransactionTemplates, userCustomExchangeRates: services.UserCustomExchangeRates, @@ -193,6 +195,13 @@ func (a *DataManagementsApi) ClearAllDataHandler(c *core.WebContext) (any, *errs return nil, errs.Or(err, errs.ErrOperationFailed) } + err = a.tagGroups.DeleteAllTagGroups(c, uid) + + if err != nil { + log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction tag groups, because %s", err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid) if err != nil { diff --git a/pkg/api/transaction_tag_groups.go b/pkg/api/transaction_tag_groups.go new file mode 100644 index 00000000..e79fe8c8 --- /dev/null +++ b/pkg/api/transaction_tag_groups.go @@ -0,0 +1,210 @@ +package api + +import ( + "sort" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/services" +) + +// TransactionTagGroupsApi represents transaction tag group api +type TransactionTagGroupsApi struct { + tagGroups *services.TransactionTagGroupService +} + +// Initialize a transaction tag group api singleton instance +var ( + TransactionTagGroups = &TransactionTagGroupsApi{ + tagGroups: services.TransactionTagGroups, + } +) + +// TagGroupListHandler returns transaction tag group list of current user +func (a *TransactionTagGroupsApi) TagGroupListHandler(c *core.WebContext) (any, *errs.Error) { + uid := c.GetCurrentUid() + tagGroups, err := a.tagGroups.GetAllTagGroupsByUid(c, uid) + + if err != nil { + log.Errorf(c, "[transaction_tag_groups.TagGroupListHandler] failed to get tag groups for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + tagGroupResps := make(models.TransactionTagGroupInfoResponseSlice, len(tagGroups)) + + for i := 0; i < len(tagGroups); i++ { + tagGroupResps[i] = tagGroups[i].ToTransactionTagGroupInfoResponse() + } + + sort.Sort(tagGroupResps) + + return tagGroupResps, nil +} + +// TagGroupGetHandler returns one specific transaction tag group of current user +func (a *TransactionTagGroupsApi) TagGroupGetHandler(c *core.WebContext) (any, *errs.Error) { + var tagGroupGetReq models.TransactionTagGroupGetRequest + err := c.ShouldBindQuery(&tagGroupGetReq) + + if err != nil { + log.Warnf(c, "[transaction_tag_groups.TagGroupGetHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagGroupGetReq.Id) + + if err != nil { + log.Errorf(c, "[transaction_tag_groups.TagGroupGetHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupGetReq.Id, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse() + + return tagGroupResp, nil +} + +// TagGroupCreateHandler saves a new transaction tag group by request parameters for current user +func (a *TransactionTagGroupsApi) TagGroupCreateHandler(c *core.WebContext) (any, *errs.Error) { + var tagGroupCreateReq models.TransactionTagGroupCreateRequest + err := c.ShouldBindJSON(&tagGroupCreateReq) + + if err != nil { + log.Warnf(c, "[transaction_tag_groups.TagGroupCreateHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + + maxOrderId, err := a.tagGroups.GetMaxDisplayOrder(c, uid) + + if err != nil { + log.Errorf(c, "[transaction_tag_groups.TagGroupCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + tagGroup := a.createNewTagGroupModel(uid, &tagGroupCreateReq, maxOrderId+1) + + err = a.tagGroups.CreateTagGroup(c, tagGroup) + + if err != nil { + log.Errorf(c, "[transaction_tag_groups.TagGroupCreateHandler] failed to create tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroup.TagGroupId, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.Infof(c, "[transaction_tag_groups.TagGroupCreateHandler] user \"uid:%d\" has created a new tag group \"id:%d\" successfully", uid, tagGroup.TagGroupId) + + tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse() + + return tagGroupResp, nil +} + +// TagGroupModifyHandler saves an existed transaction tag group by request parameters for current user +func (a *TransactionTagGroupsApi) TagGroupModifyHandler(c *core.WebContext) (any, *errs.Error) { + var tagGroupModifyReq models.TransactionTagGroupModifyRequest + err := c.ShouldBindJSON(&tagGroupModifyReq) + + if err != nil { + log.Warnf(c, "[transaction_tag_groups.TagGroupModifyHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagGroupModifyReq.Id) + + if err != nil { + log.Errorf(c, "[transaction_tag_groups.TagGroupModifyHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupModifyReq.Id, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + newTagGroup := &models.TransactionTagGroup{ + TagGroupId: tagGroup.TagGroupId, + Uid: uid, + Name: tagGroupModifyReq.Name, + } + + if newTagGroup.Name == tagGroup.Name { + return nil, errs.ErrNothingWillBeUpdated + } + + err = a.tagGroups.ModifyTagGroup(c, newTagGroup) + + if err != nil { + log.Errorf(c, "[transaction_tag_groups.TagGroupModifyHandler] failed to update tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupModifyReq.Id, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.Infof(c, "[transaction_tag_groups.TagGroupModifyHandler] user \"uid:%d\" has updated tag group \"id:%d\" successfully", uid, tagGroupModifyReq.Id) + + tagGroup.Name = newTagGroup.Name + tagGroupResp := tagGroup.ToTransactionTagGroupInfoResponse() + + return tagGroupResp, nil +} + +// TagGroupMoveHandler moves display order of existed transaction tag groups by request parameters for current user +func (a *TransactionTagGroupsApi) TagGroupMoveHandler(c *core.WebContext) (any, *errs.Error) { + var tagGroupMoveReq models.TransactionTagGroupMoveRequest + err := c.ShouldBindJSON(&tagGroupMoveReq) + + if err != nil { + log.Warnf(c, "[transaction_tag_groups.TagGroupMoveHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + tagGroups := make([]*models.TransactionTagGroup, len(tagGroupMoveReq.NewDisplayOrders)) + + for i := 0; i < len(tagGroupMoveReq.NewDisplayOrders); i++ { + newDisplayOrder := tagGroupMoveReq.NewDisplayOrders[i] + tagGroup := &models.TransactionTagGroup{ + Uid: uid, + TagGroupId: newDisplayOrder.Id, + DisplayOrder: newDisplayOrder.DisplayOrder, + } + + tagGroups[i] = tagGroup + } + + err = a.tagGroups.ModifyTagGroupDisplayOrders(c, uid, tagGroups) + + if err != nil { + log.Errorf(c, "[transaction_tag_groups.TagGroupMoveHandler] failed to move tag groups for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.Infof(c, "[transaction_tag_groups.TagGroupMoveHandler] user \"uid:%d\" has moved tag groups", uid) + return true, nil +} + +// TagGroupDeleteHandler deletes an existed transaction tag group by request parameters for current user +func (a *TransactionTagGroupsApi) TagGroupDeleteHandler(c *core.WebContext) (any, *errs.Error) { + var tagGroupDeleteReq models.TransactionTagGroupDeleteRequest + err := c.ShouldBindJSON(&tagGroupDeleteReq) + + if err != nil { + log.Warnf(c, "[transaction_tag_groups.TagGroupDeleteHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + err = a.tagGroups.DeleteTagGroup(c, uid, tagGroupDeleteReq.Id) + + if err != nil { + log.Errorf(c, "[transaction_tag_groups.TagGroupDeleteHandler] failed to delete tag group \"id:%d\" for user \"uid:%d\", because %s", tagGroupDeleteReq.Id, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.Infof(c, "[transaction_tag_groups.TagGroupDeleteHandler] user \"uid:%d\" has deleted tag group \"id:%d\"", uid, tagGroupDeleteReq.Id) + return true, nil +} + +func (a *TransactionTagGroupsApi) createNewTagGroupModel(uid int64, tagGroupCreateReq *models.TransactionTagGroupCreateRequest, order int32) *models.TransactionTagGroup { + return &models.TransactionTagGroup{ + Uid: uid, + Name: tagGroupCreateReq.Name, + DisplayOrder: order, + } +} diff --git a/pkg/api/transaction_tags.go b/pkg/api/transaction_tags.go index 0a9fba3f..cd59fcfc 100644 --- a/pkg/api/transaction_tags.go +++ b/pkg/api/transaction_tags.go @@ -78,7 +78,7 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Er uid := c.GetCurrentUid() - maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid) + maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateReq.GroupId) if err != nil { log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error()) @@ -111,9 +111,16 @@ func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *er return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) } + for i := 0; i < len(tagCreateBatchReq.Tags); i++ { + if tagCreateBatchReq.Tags[i].GroupId != tagCreateBatchReq.GroupId { + log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] the group id \"%d\" of tag#%d is inconsistent with the batch group id \"%d\"", tagCreateBatchReq.Tags[i].GroupId, i, tagCreateBatchReq.GroupId) + return nil, errs.ErrTransactionTagGroupIdInvalid + } + } + uid := c.GetCurrentUid() - maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid) + maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateBatchReq.GroupId) if err != nil { log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error()) @@ -161,16 +168,31 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er } newTag := &models.TransactionTag{ - TagId: tag.TagId, - Uid: uid, - Name: tagModifyReq.Name, + TagId: tag.TagId, + Uid: uid, + Name: tagModifyReq.Name, + TagGroupId: tagModifyReq.GroupId, + DisplayOrder: tag.DisplayOrder, } - if newTag.Name == tag.Name { + tagNameChanged := newTag.Name != tag.Name + + if !tagNameChanged && newTag.TagGroupId == tag.TagGroupId { return nil, errs.ErrNothingWillBeUpdated } - err = a.tags.ModifyTag(c, newTag) + if newTag.TagGroupId != tag.TagGroupId { + maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, newTag.TagGroupId) + + if err != nil { + log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + newTag.DisplayOrder = maxOrderId + 1 + } + + err = a.tags.ModifyTag(c, newTag, tagNameChanged) if err != nil { log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error()) @@ -180,6 +202,8 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er log.Infof(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id) tag.Name = newTag.Name + tag.TagGroupId = newTag.TagGroupId + tag.DisplayOrder = newTag.DisplayOrder tagResp := tag.ToTransactionTagInfoResponse() return tagResp, nil @@ -268,6 +292,7 @@ func (a *TransactionTagsApi) createNewTagModel(uid int64, tagCreateReq *models.T return &models.TransactionTag{ Uid: uid, Name: tagCreateReq.Name, + TagGroupId: tagCreateReq.GroupId, DisplayOrder: order, } } @@ -278,6 +303,7 @@ func (a *TransactionTagsApi) createNewTagModels(uid int64, tagCreateBatchReq *mo for i := 0; i < len(tagCreateBatchReq.Tags); i++ { tagCreateReq := tagCreateBatchReq.Tags[i] tag := a.createNewTagModel(uid, tagCreateReq, order+int32(i)) + tag.TagGroupId = tagCreateBatchReq.GroupId tags[i] = tag } diff --git a/pkg/errs/error.go b/pkg/errs/error.go index 6865a7d2..854027c8 100644 --- a/pkg/errs/error.go +++ b/pkg/errs/error.go @@ -44,6 +44,7 @@ const ( NormalSubcategoryUserExternalAuth = 16 NormalSubcategoryOAuth2 = 17 NormalSubcategoryInsightsExplorer = 18 + NormalSubcategoryTagGroup = 19 ) // Error represents the specific error returned to user diff --git a/pkg/errs/transaction_tag_group.go b/pkg/errs/transaction_tag_group.go new file mode 100644 index 00000000..bd59d190 --- /dev/null +++ b/pkg/errs/transaction_tag_group.go @@ -0,0 +1,10 @@ +package errs + +import "net/http" + +// Error codes related to transaction tag groups +var ( + ErrTransactionTagGroupIdInvalid = NewNormalError(NormalSubcategoryTagGroup, 0, http.StatusBadRequest, "transaction tag group id is invalid") + ErrTransactionTagGroupNotFound = NewNormalError(NormalSubcategoryTagGroup, 1, http.StatusBadRequest, "transaction tag group not found") + ErrTransactionTagGroupInUseCannotBeDeleted = NewNormalError(NormalSubcategoryTagGroup, 2, http.StatusBadRequest, "transaction tag group is in use and cannot be deleted") +) diff --git a/pkg/models/transaction_tag.go b/pkg/models/transaction_tag.go index 863eeefd..122af51a 100644 --- a/pkg/models/transaction_tag.go +++ b/pkg/models/transaction_tag.go @@ -3,10 +3,11 @@ package models // TransactionTag represents transaction tag data stored in database type TransactionTag struct { TagId int64 `xorm:"PK"` - Uid int64 `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"` - Deleted bool `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"` + Uid int64 `xorm:"INDEX(IDX_tag_uid_deleted_group_order) NOT NULL"` + Deleted bool `xorm:"INDEX(IDX_tag_uid_deleted_group_order) NOT NULL"` + TagGroupId int64 `xorm:"INDEX(IDX_tag_uid_deleted_group_order) NOT NULL"` Name string `xorm:"VARCHAR(64) NOT NULL"` - DisplayOrder int32 `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"` + DisplayOrder int32 `xorm:"INDEX(IDX_tag_uid_deleted_group_order) NOT NULL"` Hidden bool `xorm:"NOT NULL"` CreatedUnixTime int64 UpdatedUnixTime int64 @@ -20,19 +21,22 @@ type TransactionTagGetRequest struct { // TransactionTagCreateRequest represents all parameters of transaction tag creation request type TransactionTagCreateRequest struct { - Name string `json:"name" binding:"required,notBlank,max=64"` + GroupId int64 `json:"groupId,string"` + Name string `json:"name" binding:"required,notBlank,max=64"` } // TransactionTagCreateBatchRequest represents all parameters of transaction tag batch creation request type TransactionTagCreateBatchRequest struct { Tags []*TransactionTagCreateRequest `json:"tags" binding:"required"` + GroupId int64 `json:"groupId,string"` SkipExists bool `json:"skipExists"` } // TransactionTagModifyRequest represents all parameters of transaction tag modification request type TransactionTagModifyRequest struct { - Id int64 `json:"id,string" binding:"required,min=1"` - Name string `json:"name" binding:"required,notBlank,max=64"` + Id int64 `json:"id,string" binding:"required,min=1"` + GroupId int64 `json:"groupId,string"` + Name string `json:"name" binding:"required,notBlank,max=64"` } // TransactionTagHideRequest represents all parameters of transaction tag hiding request @@ -61,6 +65,7 @@ type TransactionTagDeleteRequest struct { type TransactionTagInfoResponse struct { Id int64 `json:"id,string"` Name string `json:"name"` + TagGroupId int64 `json:"groupId,string"` DisplayOrder int32 `json:"displayOrder"` Hidden bool `json:"hidden"` } @@ -71,6 +76,7 @@ func (t *TransactionTag) FillFromOtherTag(tag *TransactionTag) { t.Uid = tag.Uid t.Deleted = tag.Deleted t.Name = tag.Name + t.TagGroupId = tag.TagGroupId t.DisplayOrder = tag.DisplayOrder t.Hidden = tag.Hidden t.CreatedUnixTime = tag.CreatedUnixTime @@ -83,6 +89,7 @@ func (t *TransactionTag) ToTransactionTagInfoResponse() *TransactionTagInfoRespo return &TransactionTagInfoResponse{ Id: t.TagId, Name: t.Name, + TagGroupId: t.TagGroupId, DisplayOrder: t.DisplayOrder, Hidden: t.Hidden, } @@ -103,5 +110,9 @@ func (s TransactionTagInfoResponseSlice) Swap(i, j int) { // Less reports whether the first item is less than the second one func (s TransactionTagInfoResponseSlice) Less(i, j int) bool { + if s[i].TagGroupId != s[j].TagGroupId { + return s[i].TagGroupId < s[j].TagGroupId + } + return s[i].DisplayOrder < s[j].DisplayOrder } diff --git a/pkg/models/transaction_tag_group.go b/pkg/models/transaction_tag_group.go new file mode 100644 index 00000000..5d6234c2 --- /dev/null +++ b/pkg/models/transaction_tag_group.go @@ -0,0 +1,79 @@ +package models + +// TransactionTagGroup represents transaction tag group data stored in database +type TransactionTagGroup struct { + TagGroupId int64 `xorm:"PK"` + Uid int64 `xorm:"INDEX(IDX_tag_group_uid_deleted_order) NOT NULL"` + Deleted bool `xorm:"INDEX(IDX_tag_group_uid_deleted_order) NOT NULL"` + Name string `xorm:"VARCHAR(64) NOT NULL"` + DisplayOrder int32 `xorm:"INDEX(IDX_tag_group_uid_deleted_order) NOT NULL"` + CreatedUnixTime int64 + UpdatedUnixTime int64 + DeletedUnixTime int64 +} + +// TransactionTagGroupGetRequest represents all parameters of transaction tag group getting request +type TransactionTagGroupGetRequest struct { + Id int64 `form:"id,string" binding:"required,min=1"` +} + +// TransactionTagGroupCreateRequest represents all parameters of transaction tag group creation request +type TransactionTagGroupCreateRequest struct { + Name string `json:"name" binding:"required,notBlank,max=64"` +} + +// TransactionTagGroupModifyRequest represents all parameters of transaction tag group modification request +type TransactionTagGroupModifyRequest struct { + Id int64 `json:"id,string" binding:"required,min=1"` + Name string `json:"name" binding:"required,notBlank,max=64"` +} + +// TransactionTagGroupMoveRequest represents all parameters of transaction tag group moving request +type TransactionTagGroupMoveRequest struct { + NewDisplayOrders []*TransactionTagGroupNewDisplayOrderRequest `json:"newDisplayOrders" binding:"required,min=1"` +} + +// TransactionTagGroupNewDisplayOrderRequest represents a data pair of id and display order +type TransactionTagGroupNewDisplayOrderRequest struct { + Id int64 `json:"id,string" binding:"required,min=1"` + DisplayOrder int32 `json:"displayOrder"` +} + +// TransactionTagGroupDeleteRequest represents all parameters of transaction tag group deleting request +type TransactionTagGroupDeleteRequest struct { + Id int64 `json:"id,string" binding:"required,min=1"` +} + +// TransactionTagGroupInfoResponse represents a view-object of transaction tag group +type TransactionTagGroupInfoResponse struct { + Id int64 `json:"id,string"` + Name string `json:"name"` + DisplayOrder int32 `json:"displayOrder"` +} + +// ToTransactionTagGroupInfoResponse returns a view-object according to database model +func (t *TransactionTagGroup) ToTransactionTagGroupInfoResponse() *TransactionTagGroupInfoResponse { + return &TransactionTagGroupInfoResponse{ + Id: t.TagGroupId, + Name: t.Name, + DisplayOrder: t.DisplayOrder, + } +} + +// TransactionTagGroupInfoResponseSlice represents the slice data structure of TransactionTagGroupInfoResponse +type TransactionTagGroupInfoResponseSlice []*TransactionTagGroupInfoResponse + +// Len returns the count of items +func (s TransactionTagGroupInfoResponseSlice) Len() int { + return len(s) +} + +// Swap swaps two items +func (s TransactionTagGroupInfoResponseSlice) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// Less reports whether the first item is less than the second one +func (s TransactionTagGroupInfoResponseSlice) Less(i, j int) bool { + return s[i].DisplayOrder < s[j].DisplayOrder +} diff --git a/pkg/models/transaction_tag_index_test.go b/pkg/models/transaction_tag_index_test.go new file mode 100644 index 00000000..764fbef8 --- /dev/null +++ b/pkg/models/transaction_tag_index_test.go @@ -0,0 +1,30 @@ +package models + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTransactionTagGroupInfoResponseSliceLess(t *testing.T) { + var transactionTagGroupRespSlice TransactionTagGroupInfoResponseSlice + transactionTagGroupRespSlice = append(transactionTagGroupRespSlice, &TransactionTagGroupInfoResponse{ + Id: 1, + DisplayOrder: 3, + }) + transactionTagGroupRespSlice = append(transactionTagGroupRespSlice, &TransactionTagGroupInfoResponse{ + Id: 2, + DisplayOrder: 1, + }) + transactionTagGroupRespSlice = append(transactionTagGroupRespSlice, &TransactionTagGroupInfoResponse{ + Id: 3, + DisplayOrder: 2, + }) + + sort.Sort(transactionTagGroupRespSlice) + + assert.Equal(t, int64(2), transactionTagGroupRespSlice[0].Id) + assert.Equal(t, int64(3), transactionTagGroupRespSlice[1].Id) + assert.Equal(t, int64(1), transactionTagGroupRespSlice[2].Id) +} diff --git a/pkg/models/transaction_tag_test.go b/pkg/models/transaction_tag_test.go index a3f6d859..f531b09d 100644 --- a/pkg/models/transaction_tag_test.go +++ b/pkg/models/transaction_tag_test.go @@ -11,20 +11,41 @@ func TestTransactionTagInfoResponseSliceLess(t *testing.T) { var transactionTagRespSlice TransactionTagInfoResponseSlice transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{ Id: 1, + TagGroupId: 0, DisplayOrder: 3, }) transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{ Id: 2, - DisplayOrder: 1, + TagGroupId: 1, + DisplayOrder: 2, }) transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{ Id: 3, + TagGroupId: 0, + DisplayOrder: 1, + }) + transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{ + Id: 4, + TagGroupId: 2, + DisplayOrder: 1, + }) + transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{ + Id: 5, + TagGroupId: 1, + DisplayOrder: 1, + }) + transactionTagRespSlice = append(transactionTagRespSlice, &TransactionTagInfoResponse{ + Id: 6, + TagGroupId: 0, DisplayOrder: 2, }) sort.Sort(transactionTagRespSlice) - assert.Equal(t, int64(2), transactionTagRespSlice[0].Id) - assert.Equal(t, int64(3), transactionTagRespSlice[1].Id) + assert.Equal(t, int64(3), transactionTagRespSlice[0].Id) + assert.Equal(t, int64(6), transactionTagRespSlice[1].Id) assert.Equal(t, int64(1), transactionTagRespSlice[2].Id) + assert.Equal(t, int64(5), transactionTagRespSlice[3].Id) + assert.Equal(t, int64(2), transactionTagRespSlice[4].Id) + assert.Equal(t, int64(4), transactionTagRespSlice[5].Id) } diff --git a/pkg/services/transaction_tag_groups.go b/pkg/services/transaction_tag_groups.go new file mode 100644 index 00000000..1f20d2c6 --- /dev/null +++ b/pkg/services/transaction_tag_groups.go @@ -0,0 +1,220 @@ +package services + +import ( + "time" + + "xorm.io/xorm" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/datastore" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/uuid" +) + +// TransactionTagGroupService represents transaction tag group service +type TransactionTagGroupService struct { + ServiceUsingDB + ServiceUsingUuid +} + +// Initialize a transaction tag group service singleton instance +var ( + TransactionTagGroups = &TransactionTagGroupService{ + ServiceUsingDB: ServiceUsingDB{ + container: datastore.Container, + }, + ServiceUsingUuid: ServiceUsingUuid{ + container: uuid.Container, + }, + } +) + +// GetAllTagGroupsByUid returns all transaction tag group models of user +func (s *TransactionTagGroupService) GetAllTagGroupsByUid(c core.Context, uid int64) ([]*models.TransactionTagGroup, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + var tagGroups []*models.TransactionTagGroup + err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).Find(&tagGroups) + + return tagGroups, err +} + +// GetTagGroupByTagGroupId returns a transaction tag group model according to transaction tag group id +func (s *TransactionTagGroupService) GetTagGroupByTagGroupId(c core.Context, uid int64, tagGroupId int64) (*models.TransactionTagGroup, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + if tagGroupId <= 0 { + return nil, errs.ErrTransactionTagGroupIdInvalid + } + + tagGroup := &models.TransactionTagGroup{} + has, err := s.UserDataDB(uid).NewSession(c).ID(tagGroupId).Where("uid=? AND deleted=?", uid, false).Get(tagGroup) + + if err != nil { + return nil, err + } else if !has { + return nil, errs.ErrTransactionTagGroupNotFound + } + + return tagGroup, nil +} + +// GetMaxDisplayOrder returns the max display order +func (s *TransactionTagGroupService) GetMaxDisplayOrder(c core.Context, uid int64) (int32, error) { + if uid <= 0 { + return 0, errs.ErrUserIdInvalid + } + + tagGroup := &models.TransactionTagGroup{} + has, err := s.UserDataDB(uid).NewSession(c).Cols("uid", "deleted", "display_order").Where("uid=? AND deleted=?", uid, false).OrderBy("display_order desc").Limit(1).Get(tagGroup) + + if err != nil { + return 0, err + } + + if has { + return tagGroup.DisplayOrder, nil + } else { + return 0, nil + } +} + +// CreateTagGroup saves a new transaction tag group model to database +func (s *TransactionTagGroupService) CreateTagGroup(c core.Context, tagGroup *models.TransactionTagGroup) error { + if tagGroup.Uid <= 0 { + return errs.ErrUserIdInvalid + } + + tagGroup.TagGroupId = s.GenerateUuid(uuid.UUID_TYPE_TAG_GROUP) + + if tagGroup.TagGroupId < 1 { + return errs.ErrSystemIsBusy + } + + tagGroup.Deleted = false + tagGroup.CreatedUnixTime = time.Now().Unix() + tagGroup.UpdatedUnixTime = time.Now().Unix() + + return s.UserDataDB(tagGroup.Uid).DoTransaction(c, func(sess *xorm.Session) error { + _, err := sess.Insert(tagGroup) + return err + }) +} + +// ModifyTagGroup saves an existed transaction tag group model to database +func (s *TransactionTagGroupService) ModifyTagGroup(c core.Context, tagGroup *models.TransactionTagGroup) error { + if tagGroup.Uid <= 0 { + return errs.ErrUserIdInvalid + } + + tagGroup.UpdatedUnixTime = time.Now().Unix() + + return s.UserDataDB(tagGroup.Uid).DoTransaction(c, func(sess *xorm.Session) error { + updatedRows, err := sess.ID(tagGroup.TagGroupId).Cols("name", "updated_unix_time").Where("uid=? AND deleted=?", tagGroup.Uid, false).Update(tagGroup) + + if err != nil { + return err + } else if updatedRows < 1 { + return errs.ErrTransactionTagGroupNotFound + } + + return err + }) +} + +// ModifyTagGroupDisplayOrders updates display order of given transaction tag groups +func (s *TransactionTagGroupService) ModifyTagGroupDisplayOrders(c core.Context, uid int64, tagGroups []*models.TransactionTagGroup) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + for i := 0; i < len(tagGroups); i++ { + tagGroups[i].UpdatedUnixTime = time.Now().Unix() + } + + return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error { + for i := 0; i < len(tagGroups); i++ { + tagGroup := tagGroups[i] + updatedRows, err := sess.ID(tagGroup.TagGroupId).Cols("display_order", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(tagGroup) + + if err != nil { + return err + } else if updatedRows < 1 { + return errs.ErrTransactionTagGroupNotFound + } + } + + return nil + }) +} + +// DeleteTagGroup deletes an existed transaction tag group from database +func (s *TransactionTagGroupService) DeleteTagGroup(c core.Context, uid int64, tagGroupId int64) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + now := time.Now().Unix() + + updateModel := &models.TransactionTagGroup{ + Deleted: true, + DeletedUnixTime: now, + } + + return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error { + exists, err := sess.Cols("uid", "deleted").Where("uid=? AND deleted=? AND tag_group_id=?", uid, false, tagGroupId).Limit(1).Exist(&models.TransactionTag{}) + + if err != nil { + return err + } else if exists { + return errs.ErrTransactionTagGroupInUseCannotBeDeleted + } + + deletedRows, err := sess.ID(tagGroupId).Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(updateModel) + + if err != nil { + return err + } else if deletedRows < 1 { + return errs.ErrTransactionTagGroupNotFound + } + + return err + }) +} + +// DeleteAllTagGroups deletes all existed transaction tag groups from database +func (s *TransactionTagGroupService) DeleteAllTagGroups(c core.Context, uid int64) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + now := time.Now().Unix() + + updateModel := &models.TransactionTagGroup{ + Deleted: true, + DeletedUnixTime: now, + } + + return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error { + exists, err := sess.Cols("uid", "deleted").Where("uid=? AND deleted=? AND tag_group_id>?", uid, false, 0).Limit(1).Exist(&models.TransactionTag{}) + + if err != nil { + return err + } else if exists { + return errs.ErrTransactionTagGroupInUseCannotBeDeleted + } + + _, err = sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(updateModel) + + if err != nil { + return err + } + + return nil + }) +} diff --git a/pkg/services/transaction_tags.go b/pkg/services/transaction_tags.go index fd8e5b31..1f3cbf74 100644 --- a/pkg/services/transaction_tags.go +++ b/pkg/services/transaction_tags.go @@ -101,13 +101,13 @@ func (s *TransactionTagService) GetTagsByTagIds(c core.Context, uid int64, tagId } // GetMaxDisplayOrder returns the max display order -func (s *TransactionTagService) GetMaxDisplayOrder(c core.Context, uid int64) (int32, error) { +func (s *TransactionTagService) GetMaxDisplayOrder(c core.Context, uid int64, tagGroupId int64) (int32, error) { if uid <= 0 { return 0, errs.ErrUserIdInvalid } tag := &models.TransactionTag{} - has, err := s.UserDataDB(uid).NewSession(c).Cols("uid", "deleted", "display_order").Where("uid=? AND deleted=?", uid, false).OrderBy("display_order desc").Limit(1).Get(tag) + has, err := s.UserDataDB(uid).NewSession(c).Cols("uid", "deleted", "display_order").Where("uid=? AND deleted=? AND tag_group_id=?", uid, false, tagGroupId).OrderBy("display_order desc").Limit(1).Get(tag) if err != nil { return 0, err @@ -294,23 +294,25 @@ func (s *TransactionTagService) CreateTags(c core.Context, uid int64, tags []*mo } // ModifyTag saves an existed transaction tag model to database -func (s *TransactionTagService) ModifyTag(c core.Context, tag *models.TransactionTag) error { +func (s *TransactionTagService) ModifyTag(c core.Context, tag *models.TransactionTag, tagNameChanged bool) error { if tag.Uid <= 0 { return errs.ErrUserIdInvalid } - exists, err := s.ExistsTagName(c, tag.Uid, tag.Name) + if tagNameChanged { + exists, err := s.ExistsTagName(c, tag.Uid, tag.Name) - if err != nil { - return err - } else if exists { - return errs.ErrTransactionTagNameAlreadyExists + if err != nil { + return err + } else if exists { + return errs.ErrTransactionTagNameAlreadyExists + } } tag.UpdatedUnixTime = time.Now().Unix() return s.UserDataDB(tag.Uid).DoTransaction(c, func(sess *xorm.Session) error { - updatedRows, err := sess.ID(tag.TagId).Cols("name", "updated_unix_time").Where("uid=? AND deleted=?", tag.Uid, false).Update(tag) + updatedRows, err := sess.ID(tag.TagId).Cols("name", "tag_group_id", "display_order", "updated_unix_time").Where("uid=? AND deleted=?", tag.Uid, false).Update(tag) if err != nil { return err diff --git a/pkg/uuid/uuid_type.go b/pkg/uuid/uuid_type.go index 92bde847..9db61ed5 100644 --- a/pkg/uuid/uuid_type.go +++ b/pkg/uuid/uuid_type.go @@ -15,4 +15,5 @@ const ( UUID_TYPE_TEMPLATE UuidType = 7 UUID_TYPE_PICTURE UuidType = 8 UUID_TYPE_EXPLORER UuidType = 9 + UUID_TYPE_TAG_GROUP UuidType = 10 ) diff --git a/src/components/base/TransactionTagSelectionBase.ts b/src/components/base/TransactionTagSelectionBase.ts new file mode 100644 index 00000000..f51eccb3 --- /dev/null +++ b/src/components/base/TransactionTagSelectionBase.ts @@ -0,0 +1,141 @@ +import { ref, computed } from 'vue'; + +import { useI18n } from '@/locales/helpers.ts'; + +import { useTransactionTagsStore } from '@/stores/transactionTag.ts'; + +import { values } from '@/core/base.ts'; +import { DEFAULT_TAG_GROUP_ID } from '@/consts/tag.ts'; + +import { TransactionTag } from '@/models/transaction_tag.ts'; + +export type TransactionTagWithGroupHeader = TransactionTag | { + type: 'subheader'; + title: string; +} + +export interface CommonTransactionTagSelectionProps { + modelValue: string[]; + allowAddNewTag?: boolean; +} + +export function useTransactionTagSelectionBase(props: CommonTransactionTagSelectionProps, useClonedModelValue?: boolean) { + const { tt } = useI18n(); + + const transactionTagsStore = useTransactionTagsStore(); + + const clonedModelValue = ref(useClonedModelValue ? Array.from(props.modelValue) : []); + const tagSearchContent = ref(''); + + const selectedTagIds = computed>(() => { + const ret: Record = {}; + + if (useClonedModelValue) { + for (const tagId of clonedModelValue.value) { + ret[tagId] = true; + } + } else { + for (const tagId of props.modelValue) { + ret[tagId] = true; + } + } + + return ret; + }); + + const lowerCaseTagSearchContent = computed(() => tagSearchContent.value.toLowerCase()); + + const allTagsWithGroupHeader = computed(() => getTagsWithGroupHeader(tag => { + if (!tag.hidden) { + return true; + } + + if (selectedTagIds.value[tag.id]) { + return true; + } + + if (lowerCaseTagSearchContent.value && tag.name.toLowerCase().indexOf(lowerCaseTagSearchContent.value) >= 0 && isAllFilteredTagHidden.value) { + return true; + } + + return false; + })); + + const filteredTagsWithGroupHeader = computed(() => getTagsWithGroupHeader(tag => { + if (lowerCaseTagSearchContent.value) { + if (tag.name.toLowerCase().indexOf(lowerCaseTagSearchContent.value) >= 0 && (!tag.hidden || isAllFilteredTagHidden.value)) { + return true; + } else { + return false; + } + } + + return !tag.hidden || !!selectedTagIds.value[tag.id]; + })); + + const isAllFilteredTagHidden = computed(() => { + const lowerCaseTagSearchContent = tagSearchContent.value.toLowerCase(); + let hiddenCount = 0; + + for (const tag of values(transactionTagsStore.allTransactionTagsMap)) { + if (!lowerCaseTagSearchContent || tag.name.toLowerCase().indexOf(lowerCaseTagSearchContent) >= 0) { + if (!tag.hidden) { + return false; + } + + hiddenCount++; + } + } + + return hiddenCount > 0; + }); + + function getTagsWithGroupHeader(tagFilterFn: (tag: TransactionTag) => boolean): TransactionTagWithGroupHeader[] { + const result: TransactionTagWithGroupHeader[] = []; + const tagsInDefaultGroup = transactionTagsStore.allTransactionTagsByGroupMap[DEFAULT_TAG_GROUP_ID]; + + if (tagsInDefaultGroup && tagsInDefaultGroup.length > 0) { + const visibleTags = tagsInDefaultGroup.filter(tag => tagFilterFn(tag)); + + if (visibleTags.length > 0) { + result.push({ + type: 'subheader', + title: tt('Default Group') + }); + + result.push(...visibleTags); + } + } + + for (const tagGroup of transactionTagsStore.allTransactionTagGroups) { + const tags = transactionTagsStore.allTransactionTagsByGroupMap[tagGroup.id]; + + if (!tags || tags.length < 1) { + continue; + } + + const visibleTags = tags.filter(tag => tagFilterFn(tag)); + + if (visibleTags.length > 0) { + result.push({ + type: 'subheader', + title: tagGroup.name + }); + + result.push(...visibleTags); + } + } + + return result; + } + + return { + // states + clonedModelValue, + tagSearchContent, + // computed states + selectedTagIds, + allTagsWithGroupHeader, + filteredTagsWithGroupHeader + }; +} diff --git a/src/views/desktop/insights/dialogs/ExplorerRenameDialog.vue b/src/components/desktop/RenameDialog.vue similarity index 65% rename from src/views/desktop/insights/dialogs/ExplorerRenameDialog.vue rename to src/components/desktop/RenameDialog.vue index c2e7d2f5..ba1ddb3c 100644 --- a/src/views/desktop/insights/dialogs/ExplorerRenameDialog.vue +++ b/src/components/desktop/RenameDialog.vue @@ -1,20 +1,20 @@ diff --git a/src/views/mobile/tags/ListPage.vue b/src/views/mobile/tags/ListPage.vue index 141b765b..d5350415 100644 --- a/src/views/mobile/tags/ListPage.vue +++ b/src/views/mobile/tags/ListPage.vue @@ -5,7 +5,12 @@ - + + + {{ displayTagGroupName }} + + + @@ -13,6 +18,22 @@ + + + + + + + +