diff --git a/apps/api/data/mock/tag_repository.go b/apps/api/data/mock/tag_repository.go new file mode 100644 index 00000000..7ddbcfe2 --- /dev/null +++ b/apps/api/data/mock/tag_repository.go @@ -0,0 +1,203 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: domain/tag_repository.go +// +// Generated by this command: +// +// mockgen -source=domain/tag_repository.go -destination=data/mock/tag_repository.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + domain "apps/api/domain" + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockTagRepository is a mock of TagRepository interface. +type MockTagRepository struct { + ctrl *gomock.Controller + recorder *MockTagRepositoryMockRecorder + isgomock struct{} +} + +// MockTagRepositoryMockRecorder is the mock recorder for MockTagRepository. +type MockTagRepositoryMockRecorder struct { + mock *MockTagRepository +} + +// NewMockTagRepository creates a new mock instance. +func NewMockTagRepository(ctrl *gomock.Controller) *MockTagRepository { + mock := &MockTagRepository{ctrl: ctrl} + mock.recorder = &MockTagRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTagRepository) EXPECT() *MockTagRepositoryMockRecorder { + return m.recorder +} + +// BeginTransaction mocks base method. +func (m *MockTagRepository) BeginTransaction(ctx context.Context, callback func(context.Context) *domain.Error) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BeginTransaction", ctx, callback) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// BeginTransaction indicates an expected call of BeginTransaction. +func (mr *MockTagRepositoryMockRecorder) BeginTransaction(ctx, callback any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginTransaction", reflect.TypeOf((*MockTagRepository)(nil).BeginTransaction), ctx, callback) +} + +// CreateTag mocks base method. +func (m *MockTagRepository) CreateTag(ctx context.Context, tag domain.Tag) (domain.Tag, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTag", ctx, tag) + ret0, _ := ret[0].(domain.Tag) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// CreateTag indicates an expected call of CreateTag. +func (mr *MockTagRepositoryMockRecorder) CreateTag(ctx, tag any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTag", reflect.TypeOf((*MockTagRepository)(nil).CreateTag), ctx, tag) +} + +// DeleteTagById mocks base method. +func (m *MockTagRepository) DeleteTagById(ctx context.Context, id int64) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTagById", ctx, id) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// DeleteTagById indicates an expected call of DeleteTagById. +func (mr *MockTagRepositoryMockRecorder) DeleteTagById(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTagById", reflect.TypeOf((*MockTagRepository)(nil).DeleteTagById), ctx, id) +} + +// DeleteVariantTags mocks base method. +func (m *MockTagRepository) DeleteVariantTags(ctx context.Context, pairs []domain.VariantTagPair) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteVariantTags", ctx, pairs) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// DeleteVariantTags indicates an expected call of DeleteVariantTags. +func (mr *MockTagRepositoryMockRecorder) DeleteVariantTags(ctx, pairs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteVariantTags", reflect.TypeOf((*MockTagRepository)(nil).DeleteVariantTags), ctx, pairs) +} + +// GetLiveVariantIds mocks base method. +func (m *MockTagRepository) GetLiveVariantIds(ctx context.Context, variantIds []int64) ([]int64, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLiveVariantIds", ctx, variantIds) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetLiveVariantIds indicates an expected call of GetLiveVariantIds. +func (mr *MockTagRepositoryMockRecorder) GetLiveVariantIds(ctx, variantIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLiveVariantIds", reflect.TypeOf((*MockTagRepository)(nil).GetLiveVariantIds), ctx, variantIds) +} + +// GetTagById mocks base method. +func (m *MockTagRepository) GetTagById(ctx context.Context, id int64) (domain.Tag, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTagById", ctx, id) + ret0, _ := ret[0].(domain.Tag) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetTagById indicates an expected call of GetTagById. +func (mr *MockTagRepositoryMockRecorder) GetTagById(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagById", reflect.TypeOf((*MockTagRepository)(nil).GetTagById), ctx, id) +} + +// GetTagList mocks base method. +func (m *MockTagRepository) GetTagList(ctx context.Context) ([]domain.Tag, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTagList", ctx) + ret0, _ := ret[0].([]domain.Tag) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetTagList indicates an expected call of GetTagList. +func (mr *MockTagRepositoryMockRecorder) GetTagList(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagList", reflect.TypeOf((*MockTagRepository)(nil).GetTagList), ctx) +} + +// GetTagVariantIds mocks base method. +func (m *MockTagRepository) GetTagVariantIds(ctx context.Context, tagId int64) ([]int64, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTagVariantIds", ctx, tagId) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetTagVariantIds indicates an expected call of GetTagVariantIds. +func (mr *MockTagRepositoryMockRecorder) GetTagVariantIds(ctx, tagId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagVariantIds", reflect.TypeOf((*MockTagRepository)(nil).GetTagVariantIds), ctx, tagId) +} + +// GetVariantTagIds mocks base method. +func (m *MockTagRepository) GetVariantTagIds(ctx context.Context, variantId int64) ([]int64, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVariantTagIds", ctx, variantId) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetVariantTagIds indicates an expected call of GetVariantTagIds. +func (mr *MockTagRepositoryMockRecorder) GetVariantTagIds(ctx, variantId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVariantTagIds", reflect.TypeOf((*MockTagRepository)(nil).GetVariantTagIds), ctx, variantId) +} + +// InsertVariantTags mocks base method. +func (m *MockTagRepository) InsertVariantTags(ctx context.Context, pairs []domain.VariantTagPair) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertVariantTags", ctx, pairs) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// InsertVariantTags indicates an expected call of InsertVariantTags. +func (mr *MockTagRepositoryMockRecorder) InsertVariantTags(ctx, pairs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertVariantTags", reflect.TypeOf((*MockTagRepository)(nil).InsertVariantTags), ctx, pairs) +} + +// UpdateTagById mocks base method. +func (m *MockTagRepository) UpdateTagById(ctx context.Context, tag domain.Tag, id int64) (domain.Tag, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTagById", ctx, tag, id) + ret0, _ := ret[0].(domain.Tag) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// UpdateTagById indicates an expected call of UpdateTagById. +func (mr *MockTagRepositoryMockRecorder) UpdateTagById(ctx, tag, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTagById", reflect.TypeOf((*MockTagRepository)(nil).UpdateTagById), ctx, tag, id) +} diff --git a/apps/api/data/mysql/migrations/000045_create_tags.down.sql b/apps/api/data/mysql/migrations/000045_create_tags.down.sql new file mode 100644 index 00000000..67cf648c --- /dev/null +++ b/apps/api/data/mysql/migrations/000045_create_tags.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `tags`; diff --git a/apps/api/data/mysql/migrations/000045_create_tags.up.sql b/apps/api/data/mysql/migrations/000045_create_tags.up.sql new file mode 100644 index 00000000..226d4522 --- /dev/null +++ b/apps/api/data/mysql/migrations/000045_create_tags.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `tags` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `color` VARCHAR(20) NOT NULL DEFAULT 'gray', + `is_highlighted` TINYINT(1) NOT NULL DEFAULT 0, + `sort_order` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_tags_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/apps/api/data/mysql/migrations/000046_add_variant_image_url.down.sql b/apps/api/data/mysql/migrations/000046_add_variant_image_url.down.sql new file mode 100644 index 00000000..4c341285 --- /dev/null +++ b/apps/api/data/mysql/migrations/000046_add_variant_image_url.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE `variants` + DROP COLUMN `image_url`; diff --git a/apps/api/data/mysql/migrations/000046_add_variant_image_url.up.sql b/apps/api/data/mysql/migrations/000046_add_variant_image_url.up.sql new file mode 100644 index 00000000..bf15b609 --- /dev/null +++ b/apps/api/data/mysql/migrations/000046_add_variant_image_url.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `variants` + ADD COLUMN `image_url` VARCHAR(255) NULL AFTER `recipe`; diff --git a/apps/api/data/mysql/migrations/000047_create_variant_tags.down.sql b/apps/api/data/mysql/migrations/000047_create_variant_tags.down.sql new file mode 100644 index 00000000..f8ee4859 --- /dev/null +++ b/apps/api/data/mysql/migrations/000047_create_variant_tags.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `variant_tags`; diff --git a/apps/api/data/mysql/migrations/000047_create_variant_tags.up.sql b/apps/api/data/mysql/migrations/000047_create_variant_tags.up.sql new file mode 100644 index 00000000..f574b9a6 --- /dev/null +++ b/apps/api/data/mysql/migrations/000047_create_variant_tags.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS `variant_tags` ( + `variant_id` BIGINT NOT NULL, + `tag_id` BIGINT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`variant_id`, `tag_id`), + KEY `idx_variant_tags_tag_id` (`tag_id`), + CONSTRAINT `fk_variant_tags_variant` FOREIGN KEY (`variant_id`) REFERENCES `variants` (`id`), + CONSTRAINT `fk_variant_tags_tag` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/apps/api/data/mysql/tag_entity.go b/apps/api/data/mysql/tag_entity.go new file mode 100644 index 00000000..0090315e --- /dev/null +++ b/apps/api/data/mysql/tag_entity.go @@ -0,0 +1,20 @@ +package mysql + +import "time" + +type Tag struct { + Id int64 + Name string + Color string + IsHighlighted bool + SortOrder int + VariantCount int64 `gorm:"->"` + CreatedAt time.Time +} + +type VariantTag struct { + VariantId int64 `gorm:"primaryKey"` + TagId int64 `gorm:"primaryKey"` + Tag Tag + CreatedAt time.Time +} diff --git a/apps/api/data/mysql/tag_repo.go b/apps/api/data/mysql/tag_repo.go new file mode 100644 index 00000000..846d4b35 --- /dev/null +++ b/apps/api/data/mysql/tag_repo.go @@ -0,0 +1,109 @@ +package mysql + +import ( + "apps/api/domain" + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func NewTagRepository(db *gorm.DB) domain.TagRepository { + return Repository{db: db} +} + +func selectTagsWithVariantCount(db *gorm.DB) *gorm.DB { + return db.Table("tags").Select("tags.*, (SELECT COUNT(*) FROM variant_tags JOIN variants ON variants.id = variant_tags.variant_id WHERE variant_tags.tag_id = tags.id AND variants.deleted_at IS NULL) AS variant_count") +} + +func (repo Repository) GetTagList(ctx context.Context) ([]domain.Tag, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + var tags []Tag + result := selectTagsWithVariantCount(db).Order("sort_order ASC, name ASC").Find(&tags) + return ToTagListDomain(tags), ToErrorCtx(ctx, result.Error, "GetTagList") +} + +func (repo Repository) GetTagById(ctx context.Context, id int64) (domain.Tag, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + var tag Tag + result := selectTagsWithVariantCount(db).Where("tags.id = ?", id).First(&tag) + return ToTagDomain(tag), ToErrorCtx(ctx, result.Error, "GetTagById") +} + +func (repo Repository) CreateTag(ctx context.Context, tag domain.Tag) (domain.Tag, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + tagPayload := ToTagDB(tag) + result := db.Table("tags").Create(&tagPayload) + return ToTagDomain(tagPayload), ToErrorCtx(ctx, result.Error, "CreateTag") +} + +func (repo Repository) UpdateTagById(ctx context.Context, tag domain.Tag, id int64) (domain.Tag, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + tagPayload := ToTagDB(tag) + result := db.Table("tags").Where("id = ?", id). + Select("name", "color", "is_highlighted", "sort_order"). + Updates(&tagPayload) + if result.Error != nil { + return domain.Tag{}, ToErrorCtx(ctx, result.Error, "UpdateTagById") + } + + var updatedTag Tag + fetchResult := selectTagsWithVariantCount(db).Where("tags.id = ?", id).First(&updatedTag) + return ToTagDomain(updatedTag), ToErrorCtx(ctx, fetchResult.Error, "UpdateTagById") +} + +func (repo Repository) DeleteTagById(ctx context.Context, id int64) *domain.Error { + db := GetDbFromCtx(ctx, repo.db) + result := db.Table("tags").Where("id = ?", id).Delete(&Tag{}) + return ToErrorCtx(ctx, result.Error, "DeleteTagById") +} + +func (repo Repository) GetTagVariantIds(ctx context.Context, tagId int64) ([]int64, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + variantIds := []int64{} + result := db.Table("variant_tags").Where("tag_id = ?", tagId).Order("variant_id ASC").Pluck("variant_id", &variantIds) + return variantIds, ToErrorCtx(ctx, result.Error, "GetTagVariantIds") +} + +func (repo Repository) GetVariantTagIds(ctx context.Context, variantId int64) ([]int64, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + tagIds := []int64{} + result := db.Table("variant_tags").Where("variant_id = ?", variantId).Order("tag_id ASC").Pluck("tag_id", &tagIds) + return tagIds, ToErrorCtx(ctx, result.Error, "GetVariantTagIds") +} + +func (repo Repository) GetLiveVariantIds(ctx context.Context, variantIds []int64) ([]int64, *domain.Error) { + liveVariantIds := []int64{} + if len(variantIds) == 0 { + return liveVariantIds, nil + } + + db := GetDbFromCtx(ctx, repo.db) + result := db.Table("variants").Where("id IN ? AND deleted_at IS NULL", variantIds).Pluck("id", &liveVariantIds) + return liveVariantIds, ToErrorCtx(ctx, result.Error, "GetLiveVariantIds") +} + +func (repo Repository) InsertVariantTags(ctx context.Context, pairs []domain.VariantTagPair) *domain.Error { + if len(pairs) == 0 { + return nil + } + + db := GetDbFromCtx(ctx, repo.db) + payload := ToVariantTagPairsDB(pairs) + result := db.Table("variant_tags").Clauses(clause.OnConflict{DoNothing: true}).Create(&payload) + return ToErrorCtx(ctx, result.Error, "InsertVariantTags") +} + +func (repo Repository) DeleteVariantTags(ctx context.Context, pairs []domain.VariantTagPair) *domain.Error { + if len(pairs) == 0 { + return nil + } + + db := GetDbFromCtx(ctx, repo.db) + tuples := [][]interface{}{} + for _, pair := range pairs { + tuples = append(tuples, []interface{}{pair.VariantId, pair.TagId}) + } + result := db.Table("variant_tags").Where("(variant_id, tag_id) IN ?", tuples).Delete(&VariantTag{}) + return ToErrorCtx(ctx, result.Error, "DeleteVariantTags") +} diff --git a/apps/api/data/mysql/tag_repo_test.go b/apps/api/data/mysql/tag_repo_test.go new file mode 100644 index 00000000..327ba2c2 --- /dev/null +++ b/apps/api/data/mysql/tag_repo_test.go @@ -0,0 +1,142 @@ +package mysql_test + +import ( + "apps/api/data/mysql" + "apps/api/domain" + "context" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + gormmysql "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newMockTagRepository(t *testing.T) (domain.TagRepository, sqlmock.Sqlmock) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { sqlDB.Close() }) + + gormDB, err := gorm.Open(gormmysql.New(gormmysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + return mysql.NewTagRepository(gormDB), mock +} + +func TestTagRepository_Update_WritesFalseAndZeroValues(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `tags` SET `name`=\\?,`color`=\\?,`is_highlighted`=\\?,`sort_order`=\\? WHERE id = \\?"). + WithArgs("New", "green", false, 0, int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + mock.ExpectQuery("SELECT tags.\\*, \\(SELECT COUNT\\(\\*\\) FROM variant_tags .+ AS variant_count FROM `tags` WHERE tags.id = \\? ORDER BY `tags`.`id` LIMIT \\?"). + WithArgs(int64(1), 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "color", "is_highlighted", "sort_order"}). + AddRow(1, "New", "green", false, 0)) + + tag, err := repo.UpdateTagById(context.Background(), domain.Tag{Name: "New", Color: domain.TagColorGreen}, 1) + + require.Nil(t, err) + require.Equal(t, domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}, tag) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_Delete_RemovesTheRow(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM `tags` WHERE id = \\?"). + WithArgs(int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err := repo.DeleteTagById(context.Background(), 1) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_GetList_OrdersBySortOrderThenName(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectQuery("SELECT tags.\\*, \\(SELECT COUNT\\(\\*\\) FROM variant_tags .+ AS variant_count FROM `tags` ORDER BY sort_order ASC, name ASC"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "color", "is_highlighted", "sort_order", "variant_count"}). + AddRow(2, "Best Seller", "orange", true, 1, 4). + AddRow(1, "New", "green", true, 2, 0)) + + tags, err := repo.GetTagList(context.Background()) + + require.Nil(t, err) + require.Len(t, tags, 2) + require.Equal(t, "Best Seller", tags[0].Name) + require.True(t, tags[0].IsHighlighted) + require.Equal(t, int64(4), tags[0].VariantCount) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_InsertVariantTags_IgnoresPairsThatAlreadyExist(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `variant_tags` \\(`variant_id`,`tag_id`,`created_at`\\) VALUES \\(\\?,\\?,\\?\\),\\(\\?,\\?,\\?\\) ON DUPLICATE KEY UPDATE `variant_id`=`variant_id`"). + WithArgs(int64(1), int64(5), sqlmock.AnyArg(), int64(2), int64(5), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectCommit() + + err := repo.InsertVariantTags(context.Background(), []domain.VariantTagPair{ + {VariantId: 1, TagId: 5}, + {VariantId: 2, TagId: 5}, + }) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_DeleteVariantTags_RemovesOnlyTheGivenPairs(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM `variant_tags` WHERE \\(variant_id, tag_id\\) IN \\(\\(\\?,\\?\\),\\(\\?,\\?\\)\\)"). + WithArgs(int64(1), int64(5), int64(2), int64(5)). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectCommit() + + err := repo.DeleteVariantTags(context.Background(), []domain.VariantTagPair{ + {VariantId: 1, TagId: 5}, + {VariantId: 2, TagId: 5}, + }) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_GetLiveVariantIds_SkipsDeletedAndUnknownVariants(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectQuery("SELECT `id` FROM `variants` WHERE id IN \\(\\?,\\?,\\?\\) AND deleted_at IS NULL"). + WithArgs(int64(1), int64(2), int64(3)). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1).AddRow(3)) + + liveIds, err := repo.GetLiveVariantIds(context.Background(), []int64{1, 2, 3}) + + require.Nil(t, err) + require.Equal(t, []int64{1, 3}, liveIds) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_GetLiveVariantIds_EmptyInputSkipsTheQuery(t *testing.T) { + repo, mock := newMockTagRepository(t) + + liveIds, err := repo.GetLiveVariantIds(context.Background(), []int64{}) + + require.Nil(t, err) + require.Empty(t, liveIds) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/apps/api/data/mysql/tag_transformer.go b/apps/api/data/mysql/tag_transformer.go new file mode 100644 index 00000000..f9b9addd --- /dev/null +++ b/apps/api/data/mysql/tag_transformer.go @@ -0,0 +1,53 @@ +package mysql + +import "apps/api/domain" + +func ToTagDB(domainTag domain.Tag) Tag { + return Tag{ + Id: domainTag.Id, + Name: domainTag.Name, + Color: string(domainTag.Color), + IsHighlighted: domainTag.IsHighlighted, + SortOrder: domainTag.SortOrder, + CreatedAt: domainTag.CreatedAt, + } +} + +func ToTagDomain(dbTag Tag) domain.Tag { + return domain.Tag{ + Id: dbTag.Id, + Name: dbTag.Name, + Color: domain.TagColor(dbTag.Color), + IsHighlighted: dbTag.IsHighlighted, + SortOrder: dbTag.SortOrder, + VariantCount: dbTag.VariantCount, + CreatedAt: dbTag.CreatedAt, + } +} + +func ToTagListDomain(dbTags []Tag) []domain.Tag { + var domainTags []domain.Tag + for _, dbTag := range dbTags { + domainTags = append(domainTags, ToTagDomain(dbTag)) + } + return domainTags +} + +func ToVariantTagListDomain(dbVariantTags []VariantTag) []domain.VariantTag { + var domainVariantTags []domain.VariantTag + for _, dbVariantTag := range dbVariantTags { + domainVariantTags = append(domainVariantTags, domain.VariantTag{ + Tag: ToTagDomain(dbVariantTag.Tag), + TaggedAt: dbVariantTag.CreatedAt, + }) + } + return domainVariantTags +} + +func ToVariantTagPairsDB(pairs []domain.VariantTagPair) []VariantTag { + var dbVariantTags []VariantTag + for _, pair := range pairs { + dbVariantTags = append(dbVariantTags, VariantTag{VariantId: pair.VariantId, TagId: pair.TagId}) + } + return dbVariantTags +} diff --git a/apps/api/data/mysql/variant_entity.go b/apps/api/data/mysql/variant_entity.go index 9fddbc19..bb0bc323 100644 --- a/apps/api/data/mysql/variant_entity.go +++ b/apps/api/data/mysql/variant_entity.go @@ -35,11 +35,13 @@ type Variant struct { Price float32 Description *string Recipe *string + ImageUrl *string Materials []VariantMaterial DeletedAt *time.Time CreatedAt time.Time VariantValues []VariantValue PricingTiers []PricingTier + Tags []VariantTag IsAvailable bool `gorm:"default:1"` AvailableQuantity *int } diff --git a/apps/api/data/mysql/variant_repo.go b/apps/api/data/mysql/variant_repo.go index a217034a..43f7821a 100644 --- a/apps/api/data/mysql/variant_repo.go +++ b/apps/api/data/mysql/variant_repo.go @@ -17,6 +17,10 @@ func preloadVariantPricingTiers(db *gorm.DB) *gorm.DB { return db.Order("pricing_tiers.up_to_minutes ASC") } +func preloadVariantTags(db *gorm.DB) *gorm.DB { + return db.Order("variant_tags.created_at ASC") +} + func (repo Repository) GetVariantList(ctx context.Context, query string, sortBy domain.SortBy, order domain.Order, skip int, limit int, productId *int, optionValueIds []int) ([]domain.Variant, *domain.Error) { db := GetDbFromCtx(ctx, repo.db) @@ -29,6 +33,8 @@ func (repo Repository) GetVariantList(ctx context.Context, query string, sortBy Preload("VariantValues"). Preload("VariantValues.OptionValue"). Preload("PricingTiers", preloadVariantPricingTiers). + Preload("Tags", preloadVariantTags). + Preload("Tags.Tag"). Where("variants.deleted_at", nil). Order(fmt.Sprintf("%s %s", ToSortByColumn(sortBy), ToOrderColumn(order))) @@ -83,6 +89,8 @@ func (repo Repository) GetVariantById(ctx context.Context, id int64) (domain.Var Preload("VariantValues"). Preload("VariantValues.OptionValue"). Preload("PricingTiers", preloadVariantPricingTiers). + Preload("Tags", preloadVariantTags). + Preload("Tags.Tag"). Where("id = ?", id). First(&variant) return ToVariantDomain(variant), ToErrorCtx(ctx, result.Error, "GetVariantById") @@ -118,6 +126,8 @@ func (repo Repository) CreateVariant(ctx context.Context, variant domain.Variant Preload("VariantValues"). Preload("VariantValues.OptionValue"). Preload("PricingTiers", preloadVariantPricingTiers). + Preload("Tags", preloadVariantTags). + Preload("Tags.Tag"). Where("id = ?", payload.Id). First(&createdVariant) return ToVariantDomain(createdVariant), ToErrorCtx(ctx, fetchResult.Error, "CreateVariant") @@ -186,6 +196,8 @@ func (repo Repository) UpdateVariantById(ctx context.Context, variant domain.Var Preload("VariantValues"). Preload("VariantValues.OptionValue"). Preload("PricingTiers", preloadVariantPricingTiers). + Preload("Tags", preloadVariantTags). + Preload("Tags.Tag"). Where("id = ?", id). First(&updatedVariant) return ToVariantDomain(updatedVariant), ToErrorCtx(ctx, fetchResult.Error, "UpdateVariantById") diff --git a/apps/api/data/mysql/variant_transformer.go b/apps/api/data/mysql/variant_transformer.go index ec29affc..ad9d1e0c 100644 --- a/apps/api/data/mysql/variant_transformer.go +++ b/apps/api/data/mysql/variant_transformer.go @@ -46,6 +46,7 @@ func ToVariantDB(domainVariant domain.Variant) Variant { Price: domainVariant.Price, Description: domainVariant.Description, Recipe: domainVariant.Recipe, + ImageUrl: domainVariant.ImageUrl, DeletedAt: domainVariant.DeletedAt, CreatedAt: domainVariant.CreatedAt, Product: ToProductDB(domainVariant.Product), @@ -65,12 +66,14 @@ func ToVariantDomain(dbVariant Variant) domain.Variant { Price: dbVariant.Price, Description: dbVariant.Description, Recipe: dbVariant.Recipe, + ImageUrl: dbVariant.ImageUrl, DeletedAt: dbVariant.DeletedAt, CreatedAt: dbVariant.CreatedAt, Product: ToProductDomain(dbVariant.Product), Materials: ToVariantMaterialListDomain(dbVariant.Materials), VariantValues: ToVariantValueListDomain(dbVariant.VariantValues), PricingTiers: ToPricingTierListDomain(dbVariant.PricingTiers), + Tags: ToVariantTagListDomain(dbVariant.Tags), IsAvailable: dbVariant.IsAvailable, AvailableQuantity: dbVariant.AvailableQuantity, } diff --git a/apps/api/data/mysql/variant_transformer_test.go b/apps/api/data/mysql/variant_transformer_test.go index 1576a7d3..5b94b923 100644 --- a/apps/api/data/mysql/variant_transformer_test.go +++ b/apps/api/data/mysql/variant_transformer_test.go @@ -4,6 +4,7 @@ import ( "apps/api/data/mysql" "apps/api/domain" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -42,3 +43,48 @@ func TestVariantTransformerRoundTrip_AvailabilityDefaults(t *testing.T) { assert.Equal(t, false, roundTripped.IsAvailable) assert.Nil(t, roundTripped.AvailableQuantity) } + +func TestVariantTransformerRoundTrip_ImageUrl(t *testing.T) { + imageUrl := "https://example.com/ice-cream.jpg" + variant := domain.Variant{ + Id: 1, + ProductId: 2, + Name: "Ice Cream", + Price: 15000, + ImageUrl: &imageUrl, + } + + dbVariant := mysql.ToVariantDB(variant) + assert.Equal(t, &imageUrl, dbVariant.ImageUrl) + + roundTripped := mysql.ToVariantDomain(dbVariant) + assert.Equal(t, variant.ImageUrl, roundTripped.ImageUrl) +} + +func TestVariantTransformerRoundTrip_ImageUrlOmitted(t *testing.T) { + variant := domain.Variant{Id: 1, ProductId: 2, Name: "Choco", Price: 15000} + + roundTripped := mysql.ToVariantDomain(mysql.ToVariantDB(variant)) + + assert.Nil(t, roundTripped.ImageUrl) +} + +func TestVariantTransformer_TagsMapToVariantTags(t *testing.T) { + taggedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + dbVariant := mysql.Variant{ + Id: 1, + Tags: []mysql.VariantTag{ + {VariantId: 1, TagId: 7, CreatedAt: taggedAt, Tag: mysql.Tag{Id: 7, Name: "New", Color: "green"}}, + }, + } + + variant := mysql.ToVariantDomain(dbVariant) + + assert.Equal(t, []domain.VariantTag{{Tag: domain.Tag{Id: 7, Name: "New", Color: domain.TagColorGreen}, TaggedAt: taggedAt}}, variant.Tags) +} + +func TestVariantTransformer_ToVariantDBNeverCarriesTags(t *testing.T) { + variant := domain.Variant{Id: 1, Tags: []domain.VariantTag{{Tag: domain.Tag{Id: 7}}}, TagIds: []int64{7}} + + assert.Empty(t, mysql.ToVariantDB(variant).Tags) +} diff --git a/apps/api/domain/product_entity.go b/apps/api/domain/product_entity.go index 30fc14c9..46a17bb1 100644 --- a/apps/api/domain/product_entity.go +++ b/apps/api/domain/product_entity.go @@ -44,6 +44,7 @@ type Product struct { AvailableQuantity *int IsSellable bool SellableQuantity *int + Tags []ProductTag } func ResolveProductAvailability(product Product, variants []Variant) (isSellable bool, sellableQuantity *int) { diff --git a/apps/api/domain/product_usecase.go b/apps/api/domain/product_usecase.go index 0240dbdb..382f19c8 100644 --- a/apps/api/domain/product_usecase.go +++ b/apps/api/domain/product_usecase.go @@ -13,7 +13,7 @@ func NewProductUsecase(repository ProductRepository, variantRepository VariantRe return ProductUsecase{repository: repository, variantRepository: variantRepository} } -func (usecase ProductUsecase) resolveAvailability(ctx context.Context, product Product) (Product, *Error) { +func (usecase ProductUsecase) resolveDerivedFields(ctx context.Context, product Product) (Product, *Error) { productId := int(product.Id) variants, err := usecase.variantRepository.GetVariantList(ctx, "", CreatedAt, Ascending, 0, 0, &productId, []int{}) if err != nil { @@ -23,6 +23,7 @@ func (usecase ProductUsecase) resolveAvailability(ctx context.Context, product P isSellable, sellableQuantity := ResolveProductAvailability(product, variants) product.IsSellable = isSellable product.SellableQuantity = sellableQuantity + product.Tags = ResolveProductTags(variants) return product, nil } @@ -33,7 +34,7 @@ func (usecase ProductUsecase) GetProductList(ctx context.Context, query string, } for i, product := range products { - resolved, err := usecase.resolveAvailability(ctx, product) + resolved, err := usecase.resolveDerivedFields(ctx, product) if err != nil { return []Product{}, 0, err } @@ -54,7 +55,7 @@ func (usecase ProductUsecase) GetProductById(ctx context.Context, id int64) (Pro return Product{}, err } - return usecase.resolveAvailability(ctx, product) + return usecase.resolveDerivedFields(ctx, product) } func (usecase ProductUsecase) CreateProduct(ctx context.Context, product Product) (Product, *Error) { @@ -63,7 +64,7 @@ func (usecase ProductUsecase) CreateProduct(ctx context.Context, product Product return Product{}, err } - return usecase.resolveAvailability(ctx, created) + return usecase.resolveDerivedFields(ctx, created) } func (usecase ProductUsecase) UpdateProductById(ctx context.Context, product Product, id int64) (Product, *Error) { @@ -100,7 +101,7 @@ func (usecase ProductUsecase) UpdateProductById(ctx context.Context, product Pro return Product{}, err } - return usecase.resolveAvailability(ctx, updateResult) + return usecase.resolveDerivedFields(ctx, updateResult) } func (usecase ProductUsecase) DeleteProductById(ctx context.Context, id int64) *Error { diff --git a/apps/api/domain/product_usecase_test.go b/apps/api/domain/product_usecase_test.go index 067ca373..a05bb18e 100644 --- a/apps/api/domain/product_usecase_test.go +++ b/apps/api/domain/product_usecase_test.go @@ -5,6 +5,7 @@ import ( "apps/api/domain" "context" "testing" + "time" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -115,6 +116,30 @@ func TestProductUsecase_GetProductList_resolvesAvailability(t *testing.T) { assert.Nil(t, products[0].SellableQuantity) } +func TestProductUsecase_GetProductById_resolvesTags(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usecase, mockRepo, mockVariantRepo := newProductUsecase(ctrl) + + newTag := domain.Tag{Id: 1, Name: "New"} + taggedAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + mockRepo.EXPECT().GetProductById(gomock.Any(), int64(1)).Return(domain.Product{Id: 1, Name: "Pancong"}, nil) + mockVariantRepo.EXPECT().GetVariantList(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]domain.Variant{ + {Id: 1, ProductId: 1}, + {Id: 2, ProductId: 1, Tags: []domain.VariantTag{{Tag: newTag, TaggedAt: taggedAt}}}, + }, nil) + + product, err := usecase.GetProductById(context.Background(), 1) + + assert.Nil(t, err) + assert.Equal(t, []domain.ProductTag{ + {Tag: newTag, Scope: domain.ProductTagScopeVariant, VariantIds: []int64{2}, TaggedAt: taggedAt}, + }, product.Tags) +} + func TestProductUsecase_GetProductById(t *testing.T) { tests := []struct { name string diff --git a/apps/api/domain/tag_assignment.go b/apps/api/domain/tag_assignment.go new file mode 100644 index 00000000..4f756260 --- /dev/null +++ b/apps/api/domain/tag_assignment.go @@ -0,0 +1,51 @@ +package domain + +import "context" + +func uniqueInt64s(values []int64) []int64 { + seen := map[int64]bool{} + unique := []int64{} + for _, value := range values { + if !seen[value] { + seen[value] = true + unique = append(unique, value) + } + } + return unique +} + +func diffVariantTagPairs(existing []VariantTagPair, desired []VariantTagPair) (toInsert []VariantTagPair, toDelete []VariantTagPair) { + existingSet := map[VariantTagPair]bool{} + for _, pair := range existing { + existingSet[pair] = true + } + desiredSet := map[VariantTagPair]bool{} + for _, pair := range desired { + desiredSet[pair] = true + if !existingSet[pair] { + toInsert = append(toInsert, pair) + } + } + for _, pair := range existing { + if !desiredSet[pair] { + toDelete = append(toDelete, pair) + } + } + return toInsert, toDelete +} + +func applyVariantTagPairs(ctx context.Context, repository TagRepository, existing []VariantTagPair, desired []VariantTagPair) *Error { + toInsert, toDelete := diffVariantTagPairs(existing, desired) + + if len(toDelete) > 0 { + if err := repository.DeleteVariantTags(ctx, toDelete); err != nil { + return err + } + } + if len(toInsert) > 0 { + if err := repository.InsertVariantTags(ctx, toInsert); err != nil { + return err + } + } + return nil +} diff --git a/apps/api/domain/tag_entity.go b/apps/api/domain/tag_entity.go new file mode 100644 index 00000000..a7b3e5dc --- /dev/null +++ b/apps/api/domain/tag_entity.go @@ -0,0 +1,106 @@ +package domain + +import ( + "sort" + "time" +) + +type TagColor string + +const ( + TagColorRed TagColor = "red" + TagColorOrange TagColor = "orange" + TagColorYellow TagColor = "yellow" + TagColorGreen TagColor = "green" + TagColorBlue TagColor = "blue" + TagColorPurple TagColor = "purple" + TagColorPink TagColor = "pink" + TagColorGray TagColor = "gray" +) + +func (color TagColor) IsValid() bool { + switch color { + case TagColorRed, TagColorOrange, TagColorYellow, TagColorGreen, + TagColorBlue, TagColorPurple, TagColorPink, TagColorGray: + return true + } + return false +} + +type Tag struct { + Id int64 + Name string + Color TagColor + IsHighlighted bool + SortOrder int + VariantCount int64 + CreatedAt time.Time +} + +type VariantTag struct { + Tag Tag + TaggedAt time.Time +} + +type VariantTagPair struct { + VariantId int64 + TagId int64 +} + +type ProductTagScope string + +const ( + ProductTagScopeProduct ProductTagScope = "product" + ProductTagScopeVariant ProductTagScope = "variant" +) + +type ProductTag struct { + Tag Tag + Scope ProductTagScope + VariantIds []int64 + TaggedAt time.Time +} + +func ResolveProductTags(variants []Variant) []ProductTag { + liveVariantCount := 0 + productTags := []ProductTag{} + indexByTagId := map[int64]int{} + + for _, variant := range variants { + if variant.DeletedAt != nil { + continue + } + liveVariantCount++ + + for _, variantTag := range variant.Tags { + index, found := indexByTagId[variantTag.Tag.Id] + if !found { + index = len(productTags) + indexByTagId[variantTag.Tag.Id] = index + productTags = append(productTags, ProductTag{Tag: variantTag.Tag, VariantIds: []int64{}}) + } + + productTags[index].VariantIds = append(productTags[index].VariantIds, variant.Id) + if variantTag.TaggedAt.After(productTags[index].TaggedAt) { + productTags[index].TaggedAt = variantTag.TaggedAt + } + } + } + + for i := range productTags { + if len(productTags[i].VariantIds) == liveVariantCount { + productTags[i].Scope = ProductTagScopeProduct + } else { + productTags[i].Scope = ProductTagScopeVariant + } + } + + sort.SliceStable(productTags, func(i, j int) bool { + if productTags[i].Tag.SortOrder != productTags[j].Tag.SortOrder { + return productTags[i].Tag.SortOrder < productTags[j].Tag.SortOrder + } + return productTags[i].Tag.Name < productTags[j].Tag.Name + }) + + return productTags +} diff --git a/apps/api/domain/tag_entity_test.go b/apps/api/domain/tag_entity_test.go new file mode 100644 index 00000000..dab5aa32 --- /dev/null +++ b/apps/api/domain/tag_entity_test.go @@ -0,0 +1,116 @@ +package domain_test + +import ( + "apps/api/domain" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestResolveProductTags(t *testing.T) { + baseTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + newTag := domain.Tag{Id: 1, Name: "New", SortOrder: 2} + bestSeller := domain.Tag{Id: 2, Name: "Best Seller", SortOrder: 1} + + tagged := func(id int64, tags ...domain.VariantTag) domain.Variant { + return domain.Variant{Id: id, IsAvailable: true, Tags: tags} + } + at := func(tag domain.Tag, offsetDays int) domain.VariantTag { + return domain.VariantTag{Tag: tag, TaggedAt: baseTime.AddDate(0, 0, offsetDays)} + } + deletedAt := baseTime + + tests := []struct { + name string + variants []domain.Variant + expected []domain.ProductTag + }{ + { + name: "no variants", + variants: []domain.Variant{}, + expected: []domain.ProductTag{}, + }, + { + name: "untagged variants", + variants: []domain.Variant{tagged(1), tagged(2)}, + expected: []domain.ProductTag{}, + }, + { + name: "pancong — only ice cream is new, so variant scope", + variants: []domain.Variant{ + tagged(1), tagged(2), tagged(3), + tagged(4, at(newTag, 0)), + }, + expected: []domain.ProductTag{ + {Tag: newTag, Scope: domain.ProductTagScopeVariant, VariantIds: []int64{4}, TaggedAt: baseTime}, + }, + }, + { + name: "salted caramel macchiato — one of one variant, so product scope", + variants: []domain.Variant{tagged(1, at(newTag, 0))}, + expected: []domain.ProductTag{ + {Tag: newTag, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + }, + }, + { + name: "coffee latte — hot and iced both best seller, so product scope", + variants: []domain.Variant{ + tagged(1, at(bestSeller, 0)), + tagged(2, at(bestSeller, 0)), + }, + expected: []domain.ProductTag{ + {Tag: bestSeller, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1, 2}, TaggedAt: baseTime}, + }, + }, + { + name: "deleted variant is ignored for coverage and ids", + variants: []domain.Variant{ + tagged(1, at(bestSeller, 0)), + func() domain.Variant { v := tagged(2); v.DeletedAt = &deletedAt; return v }(), + func() domain.Variant { v := tagged(3, at(bestSeller, 5)); v.DeletedAt = &deletedAt; return v }(), + }, + expected: []domain.ProductTag{ + {Tag: bestSeller, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + }, + }, + { + name: "sold-out variant still counts toward coverage", + variants: []domain.Variant{ + tagged(1, at(bestSeller, 0)), + func() domain.Variant { v := tagged(2, at(bestSeller, 0)); v.IsAvailable = false; return v }(), + }, + expected: []domain.ProductTag{ + {Tag: bestSeller, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1, 2}, TaggedAt: baseTime}, + }, + }, + { + name: "taggedAt is the latest among tagged variants", + variants: []domain.Variant{ + tagged(1, at(newTag, 3)), + tagged(2, at(newTag, 7)), + tagged(3, at(newTag, 1)), + }, + expected: []domain.ProductTag{ + {Tag: newTag, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1, 2, 3}, TaggedAt: baseTime.AddDate(0, 0, 7)}, + }, + }, + { + name: "sorted by tag sort order, then name", + variants: []domain.Variant{ + tagged(1, at(newTag, 0), at(bestSeller, 0), at(domain.Tag{Id: 3, Name: "Aaa", SortOrder: 1}, 0)), + }, + expected: []domain.ProductTag{ + {Tag: domain.Tag{Id: 3, Name: "Aaa", SortOrder: 1}, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + {Tag: bestSeller, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + {Tag: newTag, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, domain.ResolveProductTags(tt.variants)) + }) + } +} diff --git a/apps/api/domain/tag_repository.go b/apps/api/domain/tag_repository.go new file mode 100644 index 00000000..f689380f --- /dev/null +++ b/apps/api/domain/tag_repository.go @@ -0,0 +1,21 @@ +//go:generate mockgen -source=tag_repository.go -destination=../data/mock/tag_repository.go -package=mock + +package domain + +import ( + "context" +) + +type TagRepository interface { + BeginTransaction(ctx context.Context, callback func(ctxWithTx context.Context) *Error) *Error + GetTagList(ctx context.Context) ([]Tag, *Error) + GetTagById(ctx context.Context, id int64) (Tag, *Error) + CreateTag(ctx context.Context, tag Tag) (Tag, *Error) + UpdateTagById(ctx context.Context, tag Tag, id int64) (Tag, *Error) + DeleteTagById(ctx context.Context, id int64) *Error + GetTagVariantIds(ctx context.Context, tagId int64) ([]int64, *Error) + GetVariantTagIds(ctx context.Context, variantId int64) ([]int64, *Error) + GetLiveVariantIds(ctx context.Context, variantIds []int64) ([]int64, *Error) + InsertVariantTags(ctx context.Context, pairs []VariantTagPair) *Error + DeleteVariantTags(ctx context.Context, pairs []VariantTagPair) *Error +} diff --git a/apps/api/domain/tag_usecase.go b/apps/api/domain/tag_usecase.go new file mode 100644 index 00000000..c6d26a2d --- /dev/null +++ b/apps/api/domain/tag_usecase.go @@ -0,0 +1,113 @@ +package domain + +import ( + "context" + "strings" + "unicode/utf8" +) + +const tagNameMaxLength = 100 + +type TagUsecase struct { + repository TagRepository +} + +func NewTagUsecase(repository TagRepository) TagUsecase { + return TagUsecase{repository: repository} +} + +func (usecase TagUsecase) GetTagList(ctx context.Context) ([]Tag, *Error) { + return usecase.repository.GetTagList(ctx) +} + +func (usecase TagUsecase) GetTagById(ctx context.Context, id int64) (Tag, *Error) { + return usecase.repository.GetTagById(ctx, id) +} + +func (usecase TagUsecase) CreateTag(ctx context.Context, tag Tag) (Tag, *Error) { + tag.Name = strings.TrimSpace(tag.Name) + if err := usecase.validateTag(ctx, tag, nil); err != nil { + return Tag{}, err + } + return usecase.repository.CreateTag(ctx, tag) +} + +func (usecase TagUsecase) UpdateTagById(ctx context.Context, tag Tag, id int64) (Tag, *Error) { + tag.Name = strings.TrimSpace(tag.Name) + if err := usecase.validateTag(ctx, tag, &id); err != nil { + return Tag{}, err + } + return usecase.repository.UpdateTagById(ctx, tag, id) +} + +func (usecase TagUsecase) DeleteTagById(ctx context.Context, id int64) *Error { + if _, err := usecase.repository.GetTagById(ctx, id); err != nil { + return err + } + return usecase.repository.DeleteTagById(ctx, id) +} + +func (usecase TagUsecase) SetTagVariants(ctx context.Context, tagId int64, variantIds []int64) (Tag, *Error) { + variantIds = uniqueInt64s(variantIds) + + err := usecase.repository.BeginTransaction(ctx, func(ctxWithTx context.Context) *Error { + if _, err := usecase.repository.GetTagById(ctxWithTx, tagId); err != nil { + return err + } + + liveVariantIds, err := usecase.repository.GetLiveVariantIds(ctxWithTx, variantIds) + if err != nil { + return err + } + if len(liveVariantIds) != len(variantIds) { + return &Error{Type: BadRequest, Message: "variant ids contain unknown or deleted variants"} + } + + existingVariantIds, err := usecase.repository.GetTagVariantIds(ctxWithTx, tagId) + if err != nil { + return err + } + + return applyVariantTagPairs(ctxWithTx, usecase.repository, + tagVariantPairs(tagId, existingVariantIds), + tagVariantPairs(tagId, variantIds), + ) + }) + if err != nil { + return Tag{}, err + } + + return usecase.repository.GetTagById(ctx, tagId) +} + +func tagVariantPairs(tagId int64, variantIds []int64) []VariantTagPair { + pairs := []VariantTagPair{} + for _, variantId := range variantIds { + pairs = append(pairs, VariantTagPair{VariantId: variantId, TagId: tagId}) + } + return pairs +} + +func (usecase TagUsecase) validateTag(ctx context.Context, tag Tag, excludeId *int64) *Error { + nameLength := utf8.RuneCountInString(tag.Name) + if nameLength < 1 || nameLength > tagNameMaxLength { + return &Error{Type: BadRequest, Message: "tag name must be between 1 and 100 characters"} + } + + if !tag.Color.IsValid() { + return &Error{Type: BadRequest, Message: "tag color is not supported"} + } + + tags, err := usecase.repository.GetTagList(ctx) + if err != nil { + return err + } + for _, existing := range tags { + isSameTag := excludeId != nil && existing.Id == *excludeId + if !isSameTag && strings.EqualFold(existing.Name, tag.Name) { + return &Error{Type: BadRequest, Message: "tag name already exists"} + } + } + + return nil +} diff --git a/apps/api/domain/tag_usecase_test.go b/apps/api/domain/tag_usecase_test.go new file mode 100644 index 00000000..82ab467f --- /dev/null +++ b/apps/api/domain/tag_usecase_test.go @@ -0,0 +1,416 @@ +package domain_test + +import ( + "apps/api/data/mock" + "apps/api/domain" + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +var existingTags = []domain.Tag{ + {Id: 1, Name: "New", Color: domain.TagColorGreen}, + {Id: 2, Name: "Best Seller", Color: domain.TagColorOrange}, +} + +func TestTagUsecase_GetTagList(t *testing.T) { + tests := []struct { + name string + setupMock func(r *mock.MockTagRepository) + expectedLen int + expectedError *domain.Error + }{ + { + name: "success", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + }, + expectedLen: 2, + }, + { + name: "repository error", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(nil, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tags, err := domain.NewTagUsecase(mockRepo).GetTagList(context.Background()) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Len(t, tags, tt.expectedLen) + } + }) + } +} + +func TestTagUsecase_GetTagById(t *testing.T) { + tests := []struct { + name string + setupMock func(r *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "success", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + }, + }, + { + name: "not found", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedError: &domain.Error{Type: domain.NotFound}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tag, err := domain.NewTagUsecase(mockRepo).GetTagById(context.Background(), 1) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Equal(t, "New", tag.Name) + } + }) + } +} + +func TestTagUsecase_CreateTag(t *testing.T) { + tests := []struct { + name string + input domain.Tag + setupMock func(r *mock.MockTagRepository) + expectedName string + expectedError *domain.Error + }{ + { + name: "success trims the name", + input: domain.Tag{Name: " Vegan ", Color: domain.TagColorBlue, IsHighlighted: true, SortOrder: 3}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + r.EXPECT().CreateTag(gomock.Any(), domain.Tag{Name: "Vegan", Color: domain.TagColorBlue, IsHighlighted: true, SortOrder: 3}). + Return(domain.Tag{Id: 3, Name: "Vegan", Color: domain.TagColorBlue, IsHighlighted: true, SortOrder: 3}, nil) + }, + expectedName: "Vegan", + }, + { + name: "duplicate name differing only in case", + input: domain.Tag{Name: "best seller", Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) { r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) }, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "unknown color", + input: domain.Tag{Name: "Vegan", Color: "teal"}, + setupMock: func(r *mock.MockTagRepository) {}, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "blank name", + input: domain.Tag{Name: " ", Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) {}, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "name longer than 100 characters", + input: domain.Tag{Name: strings.Repeat("a", 101), Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) {}, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "list error", + input: domain.Tag{Name: "Vegan", Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(nil, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + { + name: "create error", + input: domain.Tag{Name: "Vegan", Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + r.EXPECT().CreateTag(gomock.Any(), gomock.Any()).Return(domain.Tag{}, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tag, err := domain.NewTagUsecase(mockRepo).CreateTag(context.Background(), tt.input) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Equal(t, tt.expectedName, tag.Name) + } + }) + } +} + +func TestTagUsecase_UpdateTagById(t *testing.T) { + tests := []struct { + name string + id int64 + input domain.Tag + setupMock func(r *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "success keeping its own name", + id: 1, + input: domain.Tag{Name: "new", Color: domain.TagColorPurple, SortOrder: 5}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + r.EXPECT().UpdateTagById(gomock.Any(), domain.Tag{Name: "new", Color: domain.TagColorPurple, SortOrder: 5}, int64(1)). + Return(domain.Tag{Id: 1, Name: "new", Color: domain.TagColorPurple, SortOrder: 5}, nil) + }, + }, + { + name: "name taken by another tag", + id: 1, + input: domain.Tag{Name: "BEST SELLER", Color: domain.TagColorPurple}, + setupMock: func(r *mock.MockTagRepository) { r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) }, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "unknown color", + id: 1, + input: domain.Tag{Name: "New", Color: "teal"}, + setupMock: func(r *mock.MockTagRepository) {}, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "not found", + id: 99, + input: domain.Tag{Name: "Vegan", Color: domain.TagColorGreen}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + r.EXPECT().UpdateTagById(gomock.Any(), gomock.Any(), int64(99)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedError: &domain.Error{Type: domain.NotFound}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tag, err := domain.NewTagUsecase(mockRepo).UpdateTagById(context.Background(), tt.input, tt.id) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Equal(t, tt.id, tag.Id) + } + }) + } +} + +func TestTagUsecase_DeleteTagById(t *testing.T) { + tests := []struct { + name string + setupMock func(r *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "success", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + r.EXPECT().DeleteTagById(gomock.Any(), int64(1)).Return(nil) + }, + }, + { + name: "not found", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedError: &domain.Error{Type: domain.NotFound}, + }, + { + name: "repository error", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + r.EXPECT().DeleteTagById(gomock.Any(), int64(1)).Return(&domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + err := domain.NewTagUsecase(mockRepo).DeleteTagById(context.Background(), 1) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + } + }) + } +} + +func runTransactionCallback(r *mock.MockTagRepository) { + r.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, callback func(context.Context) *domain.Error) *domain.Error { + return callback(ctx) + }) +} + +func TestTagUsecase_SetTagVariants(t *testing.T) { + pair := func(variantId int64) domain.VariantTagPair { + return domain.VariantTagPair{VariantId: variantId, TagId: 1} + } + + tests := []struct { + name string + variantIds []int64 + setupMock func(r *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "replace diffs the set and only touches changed pairs", + variantIds: []int64{2, 3}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2, 3}).Return([]int64{2, 3}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + r.EXPECT().DeleteVariantTags(gomock.Any(), []domain.VariantTagPair{pair(1)}).Return(nil) + r.EXPECT().InsertVariantTags(gomock.Any(), []domain.VariantTagPair{pair(3)}).Return(nil) + }, + }, + { + name: "empty set clears every assignment", + variantIds: []int64{}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{}).Return([]int64{}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + r.EXPECT().DeleteVariantTags(gomock.Any(), []domain.VariantTagPair{pair(1), pair(2)}).Return(nil) + }, + }, + { + name: "unchanged set writes nothing", + variantIds: []int64{1, 2}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{1, 2}).Return([]int64{1, 2}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + }, + }, + { + name: "duplicate ids are collapsed", + variantIds: []int64{2, 2}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2}).Return([]int64{2}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{}, nil) + r.EXPECT().InsertVariantTags(gomock.Any(), []domain.VariantTagPair{pair(2)}).Return(nil) + }, + }, + { + name: "unknown or deleted variant id is rejected without writes", + variantIds: []int64{2, 99}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2, 99}).Return([]int64{2}, nil) + }, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "tag not found", + variantIds: []int64{2}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound}) + }, + expectedError: &domain.Error{Type: domain.NotFound}, + }, + { + name: "repository error while inserting", + variantIds: []int64{2}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2}).Return([]int64{2}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{}, nil) + r.EXPECT().InsertVariantTags(gomock.Any(), gomock.Any()).Return(&domain.Error{Type: domain.InternalServerError}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tag, err := domain.NewTagUsecase(mockRepo).SetTagVariants(context.Background(), 1, tt.variantIds) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Equal(t, int64(1), tag.Id) + } + }) + } +} diff --git a/apps/api/domain/variant_entity.go b/apps/api/domain/variant_entity.go index 2aac42a9..05deae7b 100644 --- a/apps/api/domain/variant_entity.go +++ b/apps/api/domain/variant_entity.go @@ -35,11 +35,14 @@ type Variant struct { Price float32 Description *string Recipe *string + ImageUrl *string Materials []VariantMaterial DeletedAt *time.Time CreatedAt time.Time VariantValues []VariantValue PricingTiers []PricingTier + Tags []VariantTag + TagIds []int64 IsAvailable bool AvailableQuantity *int IsSellable bool diff --git a/apps/api/domain/variant_usecase.go b/apps/api/domain/variant_usecase.go index 5b7da56a..00a6c130 100644 --- a/apps/api/domain/variant_usecase.go +++ b/apps/api/domain/variant_usecase.go @@ -7,12 +7,14 @@ import ( type VariantUsecase struct { repository VariantRepository productRepository ProductRepository + tagRepository TagRepository } -func NewVariantUsecase(repository VariantRepository, productRepository ProductRepository) VariantUsecase { +func NewVariantUsecase(repository VariantRepository, productRepository ProductRepository, tagRepository TagRepository) VariantUsecase { return VariantUsecase{ repository: repository, productRepository: productRepository, + tagRepository: tagRepository, } } @@ -53,7 +55,32 @@ func (usecase VariantUsecase) CreateVariant(ctx context.Context, variant Variant return Variant{}, err } - created, err := usecase.repository.CreateVariant(ctx, variant) + if variant.TagIds == nil { + created, err := usecase.repository.CreateVariant(ctx, variant) + if err != nil { + return Variant{}, err + } + return resolveVariantAvailability(created, product), nil + } + + var created Variant + err = usecase.repository.BeginTransaction(ctx, func(ctxWithTx context.Context) *Error { + if err := usecase.validateTagIds(ctxWithTx, variant.TagIds); err != nil { + return err + } + + createdVariant, err := usecase.repository.CreateVariant(ctxWithTx, variant) + if err != nil { + return err + } + + if err := usecase.replaceVariantTags(ctxWithTx, createdVariant.Id, variant.TagIds); err != nil { + return err + } + + created, err = usecase.repository.GetVariantById(ctxWithTx, createdVariant.Id) + return err + }) if err != nil { return Variant{}, err } @@ -80,6 +107,15 @@ func (usecase VariantUsecase) UpdateVariantById(ctx context.Context, variant Var return err } + if variant.TagIds != nil { + if err := usecase.validateTagIds(ctxWithTx, variant.TagIds); err != nil { + return err + } + if err := usecase.replaceVariantTags(ctxWithTx, id, variant.TagIds); err != nil { + return err + } + } + updated, err := usecase.repository.UpdateVariantById(ctxWithTx, variant, id) if err != nil { return err @@ -95,6 +131,40 @@ func (usecase VariantUsecase) UpdateVariantById(ctx context.Context, variant Var return resolveVariantAvailability(updateResult, product), nil } +func (usecase VariantUsecase) validateTagIds(ctx context.Context, tagIds []int64) *Error { + tags, err := usecase.tagRepository.GetTagList(ctx) + if err != nil { + return err + } + knownTagIds := map[int64]bool{} + for _, tag := range tags { + knownTagIds[tag.Id] = true + } + for _, tagId := range tagIds { + if !knownTagIds[tagId] { + return &Error{Type: BadRequest, Message: "tag ids contain unknown tags"} + } + } + return nil +} + +func (usecase VariantUsecase) replaceVariantTags(ctx context.Context, variantId int64, tagIds []int64) *Error { + existingTagIds, err := usecase.tagRepository.GetVariantTagIds(ctx, variantId) + if err != nil { + return err + } + + toVariantPairs := func(ids []int64) []VariantTagPair { + pairs := []VariantTagPair{} + for _, tagId := range uniqueInt64s(ids) { + pairs = append(pairs, VariantTagPair{VariantId: variantId, TagId: tagId}) + } + return pairs + } + + return applyVariantTagPairs(ctx, usecase.tagRepository, toVariantPairs(existingTagIds), toVariantPairs(tagIds)) +} + func resolveVariantAvailability(variant Variant, product Product) Variant { isSellable, sellableQuantity := ResolveVariantAvailability(product, variant) variant.IsSellable = isSellable diff --git a/apps/api/domain/variant_usecase_test.go b/apps/api/domain/variant_usecase_test.go index f2eaef42..f2fe5e46 100644 --- a/apps/api/domain/variant_usecase_test.go +++ b/apps/api/domain/variant_usecase_test.go @@ -11,7 +11,7 @@ import ( ) func newVariantUsecase(ctrl *gomock.Controller, variantRepo *mock.MockVariantRepository, productRepo *mock.MockProductRepository) domain.VariantUsecase { - return domain.NewVariantUsecase(variantRepo, productRepo) + return domain.NewVariantUsecase(variantRepo, productRepo, mock.NewMockTagRepository(ctrl)) } func TestVariantUsecase_GetVariantList(t *testing.T) { @@ -390,3 +390,162 @@ func TestVariantUsecase_DeleteVariantById(t *testing.T) { }) } } + +func runVariantTransactionCallback(vr *mock.MockVariantRepository) { + vr.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { + return cb(ctx) + }) +} + +func tagPairs(variantId int64, tagIds ...int64) []domain.VariantTagPair { + pairs := []domain.VariantTagPair{} + for _, tagId := range tagIds { + pairs = append(pairs, domain.VariantTagPair{VariantId: variantId, TagId: tagId}) + } + return pairs +} + +var knownTags = []domain.Tag{{Id: 1, Name: "New"}, {Id: 2, Name: "Best Seller"}, {Id: 3, Name: "Spicy"}} + +func TestVariantUsecase_UpdateVariantById_Tags(t *testing.T) { + tests := []struct { + name string + tagIds []int64 + setupMock func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "omitted tagIds leave assignments untouched", + tagIds: nil, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + vr.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Name: "Hot"}, nil) + }, + }, + { + name: "empty tagIds clear every assignment", + tagIds: []int64{}, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + tr.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + tr.EXPECT().GetVariantTagIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + tr.EXPECT().DeleteVariantTags(gomock.Any(), tagPairs(1, 1, 2)).Return(nil) + vr.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Name: "Hot"}, nil) + }, + }, + { + name: "a list replaces the set by diffing", + tagIds: []int64{2, 3}, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + tr.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + tr.EXPECT().GetVariantTagIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + tr.EXPECT().DeleteVariantTags(gomock.Any(), tagPairs(1, 1)).Return(nil) + tr.EXPECT().InsertVariantTags(gomock.Any(), tagPairs(1, 3)).Return(nil) + vr.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Name: "Hot"}, nil) + }, + }, + { + name: "an unchanged list writes nothing", + tagIds: []int64{1, 2}, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + tr.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + tr.EXPECT().GetVariantTagIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + vr.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Name: "Hot"}, nil) + }, + }, + { + name: "unknown tag id is rejected before anything is written", + tagIds: []int64{99}, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + tr.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + }, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + runVariantTransactionCallback(variantRepo) + variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, ProductId: 10}, nil) + productRepo.EXPECT().GetProductById(gomock.Any(), int64(10)).Return(domain.Product{Id: 10, SaleType: domain.SaleTypePurchase}, nil) + tt.setupMock(variantRepo, tagRepo) + + usecase := domain.NewVariantUsecase(variantRepo, productRepo, tagRepo) + _, err := usecase.UpdateVariantById(context.Background(), domain.Variant{Name: "Hot", Price: 8000, TagIds: tt.tagIds}, 1) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + } + }) + } +} + +func TestVariantUsecase_CreateVariant_Tags(t *testing.T) { + product := domain.Product{Id: 10, SaleType: domain.SaleTypePurchase} + input := domain.Variant{ProductId: 10, Name: "Hot", Price: 8000} + + t.Run("tagIds are applied to the created variant and returned", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + + withTags := input + withTags.TagIds = []int64{2} + productRepo.EXPECT().GetProductById(gomock.Any(), int64(10)).Return(product, nil) + runVariantTransactionCallback(variantRepo) + tagRepo.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + variantRepo.EXPECT().CreateVariant(gomock.Any(), withTags).Return(domain.Variant{Id: 5, ProductId: 10, Name: "Hot"}, nil) + tagRepo.EXPECT().GetVariantTagIds(gomock.Any(), int64(5)).Return([]int64{}, nil) + tagRepo.EXPECT().InsertVariantTags(gomock.Any(), tagPairs(5, 2)).Return(nil) + variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(5)).Return(domain.Variant{Id: 5, ProductId: 10, Name: "Hot", Tags: []domain.VariantTag{{Tag: knownTags[1]}}}, nil) + + created, err := domain.NewVariantUsecase(variantRepo, productRepo, tagRepo).CreateVariant(context.Background(), withTags) + + assert.Nil(t, err) + assert.Len(t, created.Tags, 1) + }) + + t.Run("omitted tagIds never touch the tag repository", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + + productRepo.EXPECT().GetProductById(gomock.Any(), int64(10)).Return(product, nil) + variantRepo.EXPECT().CreateVariant(gomock.Any(), input).Return(domain.Variant{Id: 5, ProductId: 10, Name: "Hot"}, nil) + + _, err := domain.NewVariantUsecase(variantRepo, productRepo, tagRepo).CreateVariant(context.Background(), input) + + assert.Nil(t, err) + }) + + t.Run("unknown tag id is rejected before the variant is created", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + + withTags := input + withTags.TagIds = []int64{99} + productRepo.EXPECT().GetProductById(gomock.Any(), int64(10)).Return(product, nil) + runVariantTransactionCallback(variantRepo) + tagRepo.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + + _, err := domain.NewVariantUsecase(variantRepo, productRepo, tagRepo).CreateVariant(context.Background(), withTags) + + assert.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) +} diff --git a/apps/api/main.go b/apps/api/main.go index fd607bea..5f699ade 100644 --- a/apps/api/main.go +++ b/apps/api/main.go @@ -116,6 +116,7 @@ func main() { supplierRepository := mysql.NewSupplierRepository(db) expenseRepository := mysql.NewExpenseRepository(db) categoryRepository := mysql.NewCategoryRepository(db) + tagRepository := mysql.NewTagRepository(db) couponRepository := mysql.NewCouponRepository(db) ticketRepository := mysql.NewTicketRepository(db) tableRepository := mysql.NewTableRepository(db) @@ -143,12 +144,13 @@ func main() { availabilityReservation := domain.NewAvailabilityReservation(availabilityReservationRepository) walletUsecase := domain.NewWalletUsecase(walletRepository) transactionUsecase := domain.NewTransactionUsecase(transactionRepository, variantRepository, couponRepository, walletRepository, availabilityReservation, kdsNotificationRepository, kdsNotificationUsecase, paymentRepository, guestNotificationRepository, guestNotificationUsecase, cartRepository, paymentVerificationRepository) - variantUsecase := domain.NewVariantUsecase(variantRepository, productRepository) + variantUsecase := domain.NewVariantUsecase(variantRepository, productRepository, tagRepository) productUsecase := domain.NewProductUsecase(productRepository, variantRepository) materialUsecase := domain.NewMaterialUsecase(materialRepository, supplierRepository) supplierUsecase := domain.NewSupplierUsecase(supplierRepository) expenseUsecase := domain.NewExpenseUsecase(expenseRepository, budgetRepository, walletRepository) categoryUsecase := domain.NewCategoryUsecase(categoryRepository) + tagUsecase := domain.NewTagUsecase(tagRepository) couponUsecase := domain.NewCouponUsecase(couponRepository) ticketUsecase := domain.NewTicketUsecase(ticketRepository) tableUsecase := domain.NewTableUsecase(tableRepository) @@ -174,6 +176,7 @@ func main() { supplierHandler := restapi.NewSupplierHandler(supplierUsecase) expenseHandler := restapi.NewExpenseHandler(expenseUsecase) categoryHandler := restapi.NewCategoryHandler(categoryUsecase) + tagHandler := restapi.NewTagHandler(tagUsecase) couponHandler := restapi.NewCouponHandler(couponUsecase) ticketHandler := restapi.NewTicketHandler(ticketUsecase) tableHandler := restapi.NewTableHandler(tableUsecase) @@ -195,6 +198,7 @@ func main() { restapi.NewAuthRouter(authHandler).AddRouter(router) restapi.NewBudgetRouter(budgetHandler).AddRouter(router) restapi.NewCategoryRouter(categoryHandler).AddRouter(router) + restapi.NewTagRouter(tagHandler).AddRouter(router) restapi.NewCouponRouter(couponHandler).AddRouter(router) restapi.NewTicketRouter(ticketHandler).AddRouter(router) restapi.NewTableRouter(tableHandler).AddRouter(router) diff --git a/apps/api/presentation/restapi/product_handler_test.go b/apps/api/presentation/restapi/product_handler_test.go index e8bbb417..b00a3638 100644 --- a/apps/api/presentation/restapi/product_handler_test.go +++ b/apps/api/presentation/restapi/product_handler_test.go @@ -8,6 +8,7 @@ import ( "context" "net/http" "net/http/httptest" + "strconv" "testing" "github.com/gorilla/mux" @@ -140,6 +141,55 @@ func TestProductHandler_GetProductById(t *testing.T) { } } +func TestProductHandler_GetProductById_returnsTags(t *testing.T) { + tests := []struct { + name string + variants []domain.Variant + expectedTags string + }{ + { + name: "untagged product returns an empty array", + variants: []domain.Variant{{Id: 1, ProductId: 1}}, + expectedTags: `"tags":[]`, + }, + { + name: "tagged variant of a multi-variant product has variant scope", + variants: []domain.Variant{ + {Id: 1, ProductId: 1}, + {Id: 2, ProductId: 1, Tags: []domain.VariantTag{{Tag: domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}}}}, + }, + expectedTags: `"scope":"variant","tag":`, + }, + { + name: "tag on the only variant has product scope", + variants: []domain.Variant{ + {Id: 1, ProductId: 1, Tags: []domain.VariantTag{{Tag: domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}}}}, + }, + expectedTags: `"scope":"product","tag":`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + handler, mockRepo, mockVariantRepo := newProductHandler(ctrl) + mockRepo.EXPECT().GetProductById(gomock.Any(), int64(1)).Return(domain.Product{Id: 1, Name: "Pancong"}, nil) + mockVariantRepo.EXPECT().GetVariantList(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(tt.variants, nil) + req := httptest.NewRequest(http.MethodGet, "/products/1", nil) + req = mux.SetURLVars(req, map[string]string{"productId": "1"}) + w := httptest.NewRecorder() + handler.GetProductById(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), tt.expectedTags) + if len(tt.variants[len(tt.variants)-1].Tags) > 0 { + assert.Contains(t, w.Body.String(), `"variantIds":[`+strconv.FormatInt(tt.variants[len(tt.variants)-1].Id, 10)+`]`) + } + }) + } +} + func TestProductHandler_CreateProduct(t *testing.T) { tests := []struct { name string diff --git a/apps/api/presentation/restapi/product_transformer.go b/apps/api/presentation/restapi/product_transformer.go index fa8de5e3..e1b016be 100644 --- a/apps/api/presentation/restapi/product_transformer.go +++ b/apps/api/presentation/restapi/product_transformer.go @@ -68,6 +68,11 @@ func ToApiProduct(product domain.Product) apiContract.Product { }) } + apiTags := []apiContract.ProductTag{} + for _, productTag := range product.Tags { + apiTags = append(apiTags, ToApiProductTag(productTag)) + } + return apiContract.Product{ Id: product.Id, Name: product.Name, @@ -86,6 +91,7 @@ func ToApiProduct(product domain.Product) apiContract.Product { AvailableQuantity: ToApiQuantity(product.AvailableQuantity), IsSellable: product.IsSellable, SellableQuantity: ToApiQuantity(product.SellableQuantity), + Tags: apiTags, } } diff --git a/apps/api/presentation/restapi/public_handler_test.go b/apps/api/presentation/restapi/public_handler_test.go index 76d473b3..1f3840d8 100644 --- a/apps/api/presentation/restapi/public_handler_test.go +++ b/apps/api/presentation/restapi/public_handler_test.go @@ -27,7 +27,7 @@ func newPublicHandler(t *testing.T) (restapi.PublicHandler, *mock.MockProductRep handler := restapi.NewPublicHandler( domain.NewProductUsecase(productRepo, variantRepo), domain.NewCategoryUsecase(categoryRepo), - domain.NewVariantUsecase(variantRepo, productRepo), + domain.NewVariantUsecase(variantRepo, productRepo, mock.NewMockTagRepository(ctrl)), domain.NewTableUsecase(tableRepo), ) diff --git a/apps/api/presentation/restapi/tag_handler.go b/apps/api/presentation/restapi/tag_handler.go new file mode 100644 index 00000000..d2ee2ef3 --- /dev/null +++ b/apps/api/presentation/restapi/tag_handler.go @@ -0,0 +1,134 @@ +package restapi + +import ( + "apps/api/domain" + apiContract "libs/api-contract" + "net/http" +) + +type TagHandler struct { + usecase domain.TagUsecase +} + +func NewTagHandler(usecase domain.TagUsecase) TagHandler { + return TagHandler{usecase: usecase} +} + +func (handler TagHandler) GetTagList(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + tags, err := handler.usecase.GetTagList(ctx) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(err.Type), Message: err.Message}) + return + } + + apiTags := []apiContract.Tag{} + for _, tag := range tags { + apiTags = append(apiTags, ToApiTagWithVariantCount(tag)) + } + + WriteResponse(w, apiContract.TagListResponse{Data: apiTags}) +} + +func (handler TagHandler) GetTagById(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTagId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tag, usecaseErr := handler.usecase.GetTagById(ctx, id) + if usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.TagFindByIdResponse{Data: ToApiTagWithVariantCount(tag)}) +} + +func (handler TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + tagRequest, err := GetTagRequest(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tag, usecaseErr := handler.usecase.CreateTag(ctx, ToTag(tagRequest)) + if usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.TagCreateResponse{Data: ToApiTagWithVariantCount(tag)}) +} + +func (handler TagHandler) UpdateTagById(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTagId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tagRequest, err := GetTagRequest(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tag, usecaseErr := handler.usecase.UpdateTagById(ctx, ToTag(tagRequest), id) + + if usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.TagUpdateByIdResponse{Data: ToApiTagWithVariantCount(tag)}) +} + +func (handler TagHandler) DeleteTagById(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTagId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + if err := handler.usecase.DeleteTagById(ctx, id); err != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(err.Type), Message: err.Message}) + return + } + + WriteResponse(w, apiContract.SuccessResponse{Success: true}) +} + +func (handler TagHandler) SetTagVariants(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTagId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + request, err := GetTagVariantsRequest(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tag, usecaseErr := handler.usecase.SetTagVariants(ctx, id, request.VariantIds) + if usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.TagSetVariantsResponse{Data: ToApiTagWithVariantCount(tag)}) +} diff --git a/apps/api/presentation/restapi/tag_handler_test.go b/apps/api/presentation/restapi/tag_handler_test.go new file mode 100644 index 00000000..69dcdca1 --- /dev/null +++ b/apps/api/presentation/restapi/tag_handler_test.go @@ -0,0 +1,356 @@ +package restapi_test + +import ( + "apps/api/data/mock" + "apps/api/domain" + "apps/api/presentation/restapi" + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestTagHandler_GetTagList(t *testing.T) { + tests := []struct { + name string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return([]domain.Tag{{Id: 1, Name: "New", Color: domain.TagColorGreen}}, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "repo error", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(nil, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodGet, "/tags", nil) + w := httptest.NewRecorder() + handler.GetTagList(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestTagHandler_GetTagById(t *testing.T) { + tests := []struct { + name string + tagId string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + tagId: "1", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "not found", + tagId: "99", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(99)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "invalid id", + tagId: "abc", + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodGet, "/tags/"+tt.tagId, nil) + req = mux.SetURLVars(req, map[string]string{"tagId": tt.tagId}) + w := httptest.NewRecorder() + handler.GetTagById(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestTagHandler_CreateTag(t *testing.T) { + existing := []domain.Tag{{Id: 1, Name: "New", Color: domain.TagColorGreen}} + + tests := []struct { + name string + body string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + body: `{"name": "Best Seller", "color": "orange", "isHighlighted": true, "sortOrder": 2}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + r.EXPECT().CreateTag(gomock.Any(), domain.Tag{Name: "Best Seller", Color: domain.TagColorOrange, IsHighlighted: true, SortOrder: 2}). + Return(domain.Tag{Id: 2, Name: "Best Seller", Color: domain.TagColorOrange, IsHighlighted: true, SortOrder: 2}, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid JSON body", + body: `{invalid json`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "duplicate name", + body: `{"name": "new", "color": "orange", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "unknown color", + body: `{"name": "Vegan", "color": "teal", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "repo error", + body: `{"name": "Vegan", "color": "green", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + r.EXPECT().CreateTag(gomock.Any(), gomock.Any()).Return(domain.Tag{}, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodPost, "/tags", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.CreateTag(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestTagHandler_UpdateTagById(t *testing.T) { + existing := []domain.Tag{{Id: 1, Name: "New", Color: domain.TagColorGreen}} + + tests := []struct { + name string + tagId string + body string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + tagId: "1", + body: `{"name": "Brand New", "color": "pink", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + r.EXPECT().UpdateTagById(gomock.Any(), domain.Tag{Name: "Brand New", Color: domain.TagColorPink}, int64(1)). + Return(domain.Tag{Id: 1, Name: "Brand New", Color: domain.TagColorPink}, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid id", + tagId: "abc", + body: `{"name": "Brand New", "color": "pink", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid JSON body", + tagId: "1", + body: `{invalid json`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "not found", + tagId: "99", + body: `{"name": "Brand New", "color": "pink", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + r.EXPECT().UpdateTagById(gomock.Any(), gomock.Any(), int64(99)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodPut, "/tags/"+tt.tagId, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"tagId": tt.tagId}) + w := httptest.NewRecorder() + handler.UpdateTagById(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestTagHandler_DeleteTagById(t *testing.T) { + tests := []struct { + name string + tagId string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + tagId: "1", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{Id: 1}, nil) + r.EXPECT().DeleteTagById(gomock.Any(), int64(1)).Return(nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid id", + tagId: "abc", + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "not found", + tagId: "99", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(99)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodDelete, "/tags/"+tt.tagId, nil) + req = mux.SetURLVars(req, map[string]string{"tagId": tt.tagId}) + w := httptest.NewRecorder() + handler.DeleteTagById(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestTagHandler_SetTagVariants(t *testing.T) { + tests := []struct { + name string + tagId string + body string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success returns the tag with its new variant count", + tagId: "1", + body: `{"variantIds": [2, 3]}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen, VariantCount: 2}, nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2, 3}).Return([]int64{2, 3}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{}, nil) + r.EXPECT().InsertVariantTags(gomock.Any(), []domain.VariantTagPair{{VariantId: 2, TagId: 1}, {VariantId: 3, TagId: 1}}).Return(nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "unknown variant is a validation error", + tagId: "1", + body: `{"variantIds": [99]}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{Id: 1}, nil) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{99}).Return([]int64{}, nil) + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "tag not found", + tagId: "9", + body: `{"variantIds": []}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + r.EXPECT().GetTagById(gomock.Any(), int64(9)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound}) + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "invalid id", + tagId: "abc", + body: `{"variantIds": []}`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "malformed body", + tagId: "1", + body: `{`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodPut, "/tags/"+tt.tagId+"/variants", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"tagId": tt.tagId}) + w := httptest.NewRecorder() + handler.SetTagVariants(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} diff --git a/apps/api/presentation/restapi/tag_route.go b/apps/api/presentation/restapi/tag_route.go new file mode 100644 index 00000000..f94754ab --- /dev/null +++ b/apps/api/presentation/restapi/tag_route.go @@ -0,0 +1,24 @@ +package restapi + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +type TagRouter struct { + handler TagHandler +} + +func NewTagRouter(handler TagHandler) TagRouter { + return TagRouter{handler: handler} +} + +func (tagRouter TagRouter) AddRouter(router *mux.Router) { + router.HandleFunc("/tags", CheckAuth(tagRouter.handler.GetTagList)).Methods(http.MethodGet) + router.HandleFunc("/tags/{tagId}", CheckAuth(tagRouter.handler.GetTagById)).Methods(http.MethodGet) + router.HandleFunc("/tags/{tagId}", CheckAuth(tagRouter.handler.DeleteTagById)).Methods(http.MethodDelete) + router.HandleFunc("/tags/{tagId}", CheckAuth(tagRouter.handler.UpdateTagById)).Methods(http.MethodPut, http.MethodOptions) + router.HandleFunc("/tags/{tagId}/variants", CheckAuth(tagRouter.handler.SetTagVariants)).Methods(http.MethodPut, http.MethodOptions) + router.HandleFunc("/tags", CheckAuth(tagRouter.handler.CreateTag)).Methods(http.MethodPost, http.MethodOptions) +} diff --git a/apps/api/presentation/restapi/tag_transformer.go b/apps/api/presentation/restapi/tag_transformer.go new file mode 100644 index 00000000..dbf0b85a --- /dev/null +++ b/apps/api/presentation/restapi/tag_transformer.go @@ -0,0 +1,77 @@ +package restapi + +import ( + "apps/api/domain" + "encoding/json" + apiContract "libs/api-contract" + "net/http" + "strconv" + + "github.com/gorilla/mux" +) + +func GetTagId(r *http.Request) (int64, error) { + vars := mux.Vars(r) + idParam := vars["tagId"] + id, err := strconv.ParseInt(idParam, 10, 32) + return id, err +} + +func GetTagRequest(r *http.Request) (apiContract.TagRequest, error) { + var tagRequest apiContract.TagRequest + err := json.NewDecoder(r.Body).Decode(&tagRequest) + return tagRequest, err +} + +func ToApiTag(tag domain.Tag) apiContract.Tag { + return apiContract.Tag{ + Id: tag.Id, + Name: tag.Name, + Color: string(tag.Color), + IsHighlighted: tag.IsHighlighted, + SortOrder: int32(tag.SortOrder), + CreatedAt: tag.CreatedAt, + } +} + +func ToApiTagWithVariantCount(tag domain.Tag) apiContract.Tag { + apiTag := ToApiTag(tag) + variantCount := tag.VariantCount + apiTag.VariantCount = &variantCount + return apiTag +} + +func GetTagVariantsRequest(r *http.Request) (apiContract.TagVariantsRequest, error) { + var request apiContract.TagVariantsRequest + err := json.NewDecoder(r.Body).Decode(&request) + return request, err +} + +func ToApiVariantTag(variantTag domain.VariantTag) apiContract.VariantTag { + return apiContract.VariantTag{ + Tag: ToApiTag(variantTag.Tag), + TaggedAt: variantTag.TaggedAt, + } +} + +func ToApiProductTag(productTag domain.ProductTag) apiContract.ProductTag { + variantIds := productTag.VariantIds + if variantIds == nil { + variantIds = []int64{} + } + return apiContract.ProductTag{ + Tag: ToApiTag(productTag.Tag), + Scope: string(productTag.Scope), + VariantIds: variantIds, + TaggedAt: productTag.TaggedAt, + } +} + +func ToTag(tagRequest apiContract.TagRequest) domain.Tag { + return domain.Tag{ + Name: tagRequest.Name, + Color: domain.TagColor(tagRequest.Color), + IsHighlighted: tagRequest.IsHighlighted, + SortOrder: int(tagRequest.SortOrder), + } +} diff --git a/apps/api/presentation/restapi/variant_handler_test.go b/apps/api/presentation/restapi/variant_handler_test.go index ddcb9b3e..82b42e1a 100644 --- a/apps/api/presentation/restapi/variant_handler_test.go +++ b/apps/api/presentation/restapi/variant_handler_test.go @@ -6,9 +6,11 @@ import ( "apps/api/presentation/restapi" "bytes" "context" + "encoding/json" "net/http" "net/http/httptest" "testing" + "time" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" @@ -16,7 +18,7 @@ import ( ) func newTestVariantHandler(ctrl *gomock.Controller, variantRepo *mock.MockVariantRepository, productRepo *mock.MockProductRepository) restapi.VariantHandler { - return restapi.NewVariantHandler(domain.NewVariantUsecase(variantRepo, productRepo)) + return restapi.NewVariantHandler(domain.NewVariantUsecase(variantRepo, productRepo, mock.NewMockTagRepository(ctrl))) } func TestVariantHandler_GetVariantList(t *testing.T) { @@ -223,6 +225,61 @@ func TestVariantHandler_UpdateVariantById(t *testing.T) { } } +func TestVariantHandler_UpdateVariantById_ImageUrl(t *testing.T) { + imageUrl := "https://example.com/ice-cream.jpg" + + tests := []struct { + name string + body string + expectedImageUrl *string + }{ + { + name: "with imageUrl", + body: `{"productId": 1, "name": "Ice Cream", "price": 20000, "imageUrl": "https://example.com/ice-cream.jpg", "materials": [], "values": []}`, + expectedImageUrl: &imageUrl, + }, + { + name: "without imageUrl", + body: `{"productId": 1, "name": "Ice Cream", "price": 20000, "materials": [], "values": []}`, + expectedImageUrl: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + variantRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, ProductId: 1}, nil) + productRepo.EXPECT().GetProductById(gomock.Any(), int64(1)).Return(domain.Product{Id: 1, SaleType: domain.SaleTypePurchase}, nil) + variantRepo.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).DoAndReturn( + func(ctx context.Context, variant domain.Variant, id int64) (domain.Variant, *domain.Error) { + assert.Equal(t, tt.expectedImageUrl, variant.ImageUrl) + return variant, nil + }) + + handler := newTestVariantHandler(ctrl, variantRepo, productRepo) + req := httptest.NewRequest(http.MethodPut, "/variants/1", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"variantId": "1"}) + w := httptest.NewRecorder() + handler.UpdateVariantById(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var response struct { + Data struct { + ImageUrl *string `json:"imageUrl"` + } `json:"data"` + } + assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Equal(t, tt.expectedImageUrl, response.Data.ImageUrl) + }) + } +} + func TestVariantHandler_DeleteVariantById(t *testing.T) { tests := []struct { name string @@ -270,3 +327,78 @@ func TestVariantHandler_DeleteVariantById(t *testing.T) { }) } } + +func TestVariantHandler_UpdateVariantById_TagIds(t *testing.T) { + tests := []struct { + name string + body string + expectedTagIds []int64 + }{ + { + name: "omitted tagIds stay nil", + body: `{"productId": 1, "name": "Hot", "price": 20000, "materials": [], "values": []}`, + expectedTagIds: nil, + }, + { + name: "empty tagIds stay empty", + body: `{"productId": 1, "name": "Hot", "price": 20000, "materials": [], "values": [], "tagIds": []}`, + expectedTagIds: []int64{}, + }, + { + name: "tagIds are passed through", + body: `{"productId": 1, "name": "Hot", "price": 20000, "materials": [], "values": [], "tagIds": [1, 2]}`, + expectedTagIds: []int64{1, 2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + variantRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, ProductId: 1}, nil) + productRepo.EXPECT().GetProductById(gomock.Any(), int64(1)).Return(domain.Product{Id: 1, SaleType: domain.SaleTypePurchase}, nil) + if tt.expectedTagIds != nil { + tagRepo.EXPECT().GetTagList(gomock.Any()).Return([]domain.Tag{{Id: 1}, {Id: 2}}, nil) + tagRepo.EXPECT().GetVariantTagIds(gomock.Any(), int64(1)).Return([]int64{}, nil) + if len(tt.expectedTagIds) > 0 { + tagRepo.EXPECT().InsertVariantTags(gomock.Any(), gomock.Any()).Return(nil) + } + } + taggedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + variantRepo.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).DoAndReturn( + func(ctx context.Context, variant domain.Variant, id int64) (domain.Variant, *domain.Error) { + assert.Equal(t, tt.expectedTagIds, variant.TagIds) + variant.Tags = []domain.VariantTag{{Tag: domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}, TaggedAt: taggedAt}} + return variant, nil + }) + + handler := restapi.NewVariantHandler(domain.NewVariantUsecase(variantRepo, productRepo, tagRepo)) + req := httptest.NewRequest(http.MethodPut, "/variants/1", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"variantId": "1"}) + w := httptest.NewRecorder() + handler.UpdateVariantById(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var response struct { + Data struct { + Tags []struct { + Tag struct { + Name string `json:"name"` + } `json:"tag"` + TaggedAt time.Time `json:"taggedAt"` + } `json:"tags"` + } `json:"data"` + } + assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Len(t, response.Data.Tags, 1) + assert.Equal(t, "New", response.Data.Tags[0].Tag.Name) + assert.True(t, taggedAt.Equal(response.Data.Tags[0].TaggedAt)) + }) + } +} diff --git a/apps/api/presentation/restapi/variant_transformer.go b/apps/api/presentation/restapi/variant_transformer.go index fe8c3a2c..87d5906d 100644 --- a/apps/api/presentation/restapi/variant_transformer.go +++ b/apps/api/presentation/restapi/variant_transformer.go @@ -88,6 +88,11 @@ func ToApiVariant(variant domain.Variant) apiContract.Variant { apiPricingTiers = append(apiPricingTiers, ToApiPricingTier(tier)) } + apiTags := []apiContract.VariantTag{} + for _, variantTag := range variant.Tags { + apiTags = append(apiTags, ToApiVariantTag(variantTag)) + } + return apiContract.Variant{ Id: variant.Id, Name: variant.Name, @@ -99,8 +104,10 @@ func ToApiVariant(variant domain.Variant) apiContract.Variant { CreatedAt: variant.CreatedAt, Description: variant.Description, Recipe: variant.Recipe, + ImageUrl: variant.ImageUrl, Values: apiVariantValues, PricingTiers: apiPricingTiers, + Tags: apiTags, IsAvailable: variant.IsAvailable, AvailableQuantity: ToApiQuantity(variant.AvailableQuantity), IsSellable: variant.IsSellable, @@ -149,7 +156,9 @@ func ToVariant(variantRequest apiContract.VariantRequest) domain.Variant { Materials: variantMaterials, Description: variantRequest.Description, Recipe: variantRequest.Recipe, + ImageUrl: variantRequest.ImageUrl, VariantValues: variantValues, PricingTiers: pricingTiers, + TagIds: variantRequest.TagIds, } } diff --git a/apps/order-web-e2e/src/tags.spec.ts b/apps/order-web-e2e/src/tags.spec.ts new file mode 100644 index 00000000..d027ec6f --- /dev/null +++ b/apps/order-web-e2e/src/tags.spec.ts @@ -0,0 +1,153 @@ +import { test, expect } from '@playwright/test'; +import * as api from './utils/api'; +import * as sel from './utils/selectors'; + +const TS = Date.now(); +const CATEGORY_NAME = `E2E TagKategori ${TS}`; +const TABLE_LABEL = `E2E TagMeja ${TS}`; +const PRODUCT_NAME = `E2E Pancong ${TS}`; +const TAG_NAME = `E2E Baru ${TS}`; +const TAGGED_VARIANT_NAME = 'Es Krim'; +const UNTAGGED_VARIANT_NAME = 'Coklat'; +const TAGGED_VARIANT_PRICE = 22_000; +const UNTAGGED_VARIANT_PRICE = 15_000; + +test.describe.serial('Highlighted tags (order app)', () => { + let category: api.Category; + let table: api.Table; + let product: api.Product; + let taggedVariant: api.Variant; + let untaggedVariant: api.Variant; + let tag: api.Tag; + + test.beforeAll(async () => { + category = await api.createCategory({ + name: CATEGORY_NAME, + station: 'NONE', + }); + table = await api.createTable({ label: TABLE_LABEL }); + + product = await api.createProduct({ + categoryId: category.id, + name: PRODUCT_NAME, + imageUrl: '', + saleType: 'purchase', + status: 'published', + options: [ + { + name: 'Topping', + values: [ + { name: UNTAGGED_VARIANT_NAME }, + { name: TAGGED_VARIANT_NAME }, + ], + }, + ], + }); + const [untaggedValue, taggedValue] = product.options[0].values; + + untaggedVariant = await api.createVariant({ + productId: product.id, + name: UNTAGGED_VARIANT_NAME, + price: UNTAGGED_VARIANT_PRICE, + materials: [], + values: [{ optionValueId: untaggedValue.id }], + }); + taggedVariant = await api.createVariant({ + productId: product.id, + name: TAGGED_VARIANT_NAME, + price: TAGGED_VARIANT_PRICE, + materials: [], + values: [{ optionValueId: taggedValue.id }], + }); + + tag = await api.createTag({ + name: TAG_NAME, + color: 'green', + isHighlighted: true, + sortOrder: 0, + }); + await api.setTagVariants(tag.id, [taggedVariant.id]); + }); + + test.afterAll(async () => { + const cleanup = [ + () => api.deleteTag(tag.id), + () => api.deleteVariant(taggedVariant.id), + () => api.deleteVariant(untaggedVariant.id), + () => api.deleteProduct(product.id), + () => api.deleteCategory(category.id), + () => api.deleteTable(table.id), + ]; + for (const step of cleanup) { + await step().catch(() => { + // Ignore — the row may not exist if an earlier step failed. + }); + } + }); + + test('a highlight section appears above the categories', async ({ + page, + }) => { + await page.goto(`t/${table.code}`); + + const highlightCard = sel.menuList.highlightCard( + page, + PRODUCT_NAME, + TAGGED_VARIANT_NAME + ); + await expect(highlightCard).toBeVisible(); + await expect( + sel.menuList.tagSectionHeading(page, TAG_NAME).first() + ).toBeVisible(); + + const regularCard = sel.menuList.productCard(page, PRODUCT_NAME); + await expect(regularCard).toBeVisible(); + + const highlightBox = await highlightCard.boundingBox(); + const regularBox = await regularCard.boundingBox(); + expect(highlightBox?.y).toBeLessThan(regularBox?.y ?? Number.NEGATIVE_INFINITY); + }); + + test('a variant card opens the item with its option pre-selected', async ({ + page, + }) => { + await page.goto(`t/${table.code}`); + + await sel.menuList + .highlightCard(page, PRODUCT_NAME, TAGGED_VARIANT_NAME) + .click(); + + await expect(sel.itemDetail.addToCartButton(page)).toBeEnabled(); + await expect( + sel.itemDetail.addToCartButton(page).getByText(/22\.000/) + ).toBeVisible(); + }); + + test('the tag chip filters the menu to that tag', async ({ page }) => { + await page.goto(`t/${table.code}`); + + await sel.menuList.tagChip(page, TAG_NAME).click(); + + await expect( + sel.menuList.highlightCard(page, PRODUCT_NAME, TAGGED_VARIANT_NAME) + ).toBeVisible(); + await expect( + sel.menuList.productCard(page, PRODUCT_NAME) + ).toBeHidden(); + + await sel.menuList.categoryChip(page, 'Semua').click(); + + await expect(sel.menuList.productCard(page, PRODUCT_NAME)).toBeVisible(); + }); + + test('highlight sections are hidden while searching', async ({ page }) => { + await page.goto(`t/${table.code}`); + + await sel.menuList.searchInput(page).fill(PRODUCT_NAME); + + await expect(sel.menuList.productCard(page, PRODUCT_NAME)).toBeVisible(); + await expect( + sel.menuList.highlightCard(page, PRODUCT_NAME, TAGGED_VARIANT_NAME) + ).toBeHidden(); + }); +}); diff --git a/apps/order-web-e2e/src/utils/api.ts b/apps/order-web-e2e/src/utils/api.ts index 8bc0b114..adb72ba5 100644 --- a/apps/order-web-e2e/src/utils/api.ts +++ b/apps/order-web-e2e/src/utils/api.ts @@ -301,3 +301,44 @@ export async function updateAvailability( ): Promise { await apiPut('/availability', data); } + +export type TagColor = + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | 'pink' + | 'gray'; + +export interface Tag { + id: number; + name: string; + color: TagColor; + isHighlighted: boolean; + sortOrder: number; + createdAt: string; +} + +export interface CreateTagInput { + name: string; + color: TagColor; + isHighlighted: boolean; + sortOrder: number; +} + +export async function createTag(data: CreateTagInput): Promise { + return apiPost('/tags', data); +} + +export async function deleteTag(id: number): Promise { + return apiDelete(`/tags/${id}`); +} + +export async function setTagVariants( + tagId: number, + variantIds: number[] +): Promise { + await apiPut(`/tags/${tagId}/variants`, { variantIds }); +} diff --git a/apps/order-web-e2e/src/utils/selectors.ts b/apps/order-web-e2e/src/utils/selectors.ts index 60237417..55ec93f9 100644 --- a/apps/order-web-e2e/src/utils/selectors.ts +++ b/apps/order-web-e2e/src/utils/selectors.ts @@ -20,6 +20,15 @@ export const menuList = { startingPrice: (page: Page, formattedPrice: string) => page.getByText(`mulai ${formattedPrice}`), emptyView: (page: Page) => page.getByText('Menu tidak ditemukan'), + tagChip: (page: Page, name: string) => + page.getByRole('button', { name, exact: true }), + tagSectionHeading: (page: Page, name: string) => + page.getByText(name, { exact: true }), + highlightCard: (page: Page, productName: string, variantName?: string) => + page.getByRole('button', { + name: variantName ? `${productName} · ${variantName}` : productName, + exact: true, + }), }; export const itemDetail = { diff --git a/apps/pos-web-e2e/src/tags.spec.ts b/apps/pos-web-e2e/src/tags.spec.ts new file mode 100644 index 00000000..a7a57300 --- /dev/null +++ b/apps/pos-web-e2e/src/tags.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from '@playwright/test'; +import * as api from './utils/api'; +import * as sel from './utils/selectors'; + +const TS = Date.now(); +const TAG_NAME = `E2E Tag ${TS}`; +const CATEGORY_NAME = `E2E TagCategory ${TS}`; +const PRODUCT_NAME = `E2E TagProduct ${TS}`; +const PRICE = 25_000; + +test.describe.serial('Tag Management', () => { + let testCategory: api.Category; + let testProduct: api.Product; + let testVariant: api.Variant; + + test.beforeAll(async ({ request }) => { + testCategory = await api.createCategory(request, { name: CATEGORY_NAME }); + + testProduct = await api.createProduct(request, { + categoryId: testCategory.id, + name: PRODUCT_NAME, + imageUrl: 'https://placehold.co/400x400.jpg', + saleType: 'purchase', + options: [{ name: 'Size', values: [{ name: 'Standard' }] }], + }); + + const optionValueId = testProduct.options[0]?.values[0]?.id; + if (!optionValueId) { + throw new Error('Product option value ID missing from API response'); + } + + testVariant = await api.createVariant(request, { + productId: testProduct.id, + name: 'Standard', + price: PRICE, + materials: [], + values: [{ optionValueId }], + }); + }); + + test.afterAll(async ({ request }) => { + const tags = await api.listTags(request).catch(() => []); + for (const tag of tags.filter((candidate) => candidate.name === TAG_NAME)) { + await api.deleteTag(request, tag.id).catch(() => { + // Ignore — tag may already be gone + }); + } + await api.deleteVariant(request, testVariant.id).catch(() => { + // Ignore — variant may already be gone + }); + await api.deleteProduct(request, testProduct.id).catch(() => { + // Ignore — product may already be gone + }); + await api.deleteCategory(request, testCategory.id).catch(() => { + // Ignore — category may already be gone + }); + }); + + test('should create a new tag', async ({ page }) => { + await page.goto('/tags/create'); + + await expect(sel.tagForm.submitButton(page)).toBeVisible({ + timeout: 15_000, + }); + + await sel.tagForm.nameInput(page).fill(TAG_NAME); + await sel.tagForm.colorOption(page, 'Green').click(); + + await sel.tagForm.submitButton(page).click(); + + await page.waitForURL('/tags', { timeout: 15_000 }); + await expect(sel.tagList.tagItem(page, TAG_NAME)).toBeVisible({ + timeout: 15_000, + }); + await expect(sel.tagList.variantCount(page, TAG_NAME, 0)).toBeVisible(); + }); + + test('should assign the tag to a product', async ({ page }) => { + await page.goto('/tags', { waitUntil: 'domcontentloaded' }); + + await sel.tagList.menuButton(page, TAG_NAME).click(); + await sel.tagList.menuOption(page, 'Assign products').click(); + + await page.waitForURL(/\/tags\/\d+\/assign$/, { timeout: 15_000 }); + + await sel.tagAssignment.searchInput(page).fill(PRODUCT_NAME); + const productCheckbox = sel.tagAssignment.productCheckbox( + page, + PRODUCT_NAME + ); + await expect(productCheckbox).toBeVisible({ timeout: 15_000 }); + await productCheckbox.click(); + await expect(productCheckbox).toBeChecked(); + + await sel.tagAssignment.saveButton(page).click(); + + await page.waitForURL('/tags', { timeout: 15_000 }); + await expect(sel.tagList.variantCount(page, TAG_NAME, 1)).toBeVisible({ + timeout: 15_000, + }); + }); + + test('should show the tag badge in the transaction item picker', async ({ + page, + }) => { + await page.goto('/transactions/create'); + + await expect(sel.transactionForm.submitButton(page)).toBeVisible({ + timeout: 15_000, + }); + + await sel.transactionForm.productSearchInput(page).fill(PRODUCT_NAME); + + const productCard = sel.transactionForm.productCard(page, PRODUCT_NAME); + await expect(productCard).toBeVisible({ timeout: 15_000 }); + await expect( + productCard.locator('xpath=../..').getByText(TAG_NAME, { exact: true }) + ).toBeVisible(); + }); + + test('should delete the tag', async ({ page }) => { + await page.goto('/tags', { waitUntil: 'domcontentloaded' }); + + await sel.tagList.menuButton(page, TAG_NAME).click(); + await sel.tagList.menuOption(page, 'Delete').click(); + await sel.common.confirmButton(page).click(); + + await expect(sel.tagList.tagItem(page, TAG_NAME)).toBeHidden({ + timeout: 10_000, + }); + }); +}); diff --git a/apps/pos-web-e2e/src/utils/api.ts b/apps/pos-web-e2e/src/utils/api.ts index 2f4afb4f..963e5acc 100644 --- a/apps/pos-web-e2e/src/utils/api.ts +++ b/apps/pos-web-e2e/src/utils/api.ts @@ -44,6 +44,25 @@ export interface OptionValue { name: string; } +export type TagColor = + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | 'pink' + | 'gray'; + +export interface Tag { + id: number; + name: string; + color: TagColor; + isHighlighted: boolean; + sortOrder: number; + createdAt: string; +} + export interface Budget { id: number; name: string; @@ -582,3 +601,14 @@ export async function updateAvailability( ): Promise { await apiPut(request, '/api/availability', data); } + +export async function listTags(request: APIRequestContext): Promise { + return apiGet(request, '/api/tags'); +} + +export async function deleteTag( + request: APIRequestContext, + id: number +): Promise { + return apiDelete(request, `/api/tags/${id}`); +} diff --git a/apps/pos-web-e2e/src/utils/selectors.ts b/apps/pos-web-e2e/src/utils/selectors.ts index ca3df68c..bcb8c885 100644 --- a/apps/pos-web-e2e/src/utils/selectors.ts +++ b/apps/pos-web-e2e/src/utils/selectors.ts @@ -341,3 +341,38 @@ export const availabilityForm = { availabilityForm.row(page, rowName).getByText('Sold out'), saveButton: (page: Page) => page.getByRole('button', { name: 'Save' }).first(), }; + +export const tagList = { + createLink: (page: Page) => page.locator('a[href="/tags/create"]'), + tagItem: (page: Page, name: string) => + page.locator('h4').filter({ hasText: name }).first(), + menuButton: (page: Page, name: string) => + page + .locator('h4') + .filter({ hasText: name }) + .locator('../../../..') + .getByRole('button') + .last(), + menuOption: (page: Page, label: 'Assign products' | 'Edit' | 'Delete') => + page.locator('[data-state="open"] li').filter({ hasText: label }).last(), + variantCount: (page: Page, name: string, count: number) => + page + .locator('h4') + .filter({ hasText: name }) + .locator('../../../..') + .getByText(`${count} ${count === 1 ? 'variant' : 'variants'}`), +}; + +export const tagForm = { + nameInput: (page: Page) => page.getByLabel('Name'), + colorOption: (page: Page, label: string) => + page.getByRole('radio', { name: label, exact: true }), + submitButton: (page: Page) => page.getByRole('button', { name: 'Submit' }), +}; + +export const tagAssignment = { + searchInput: (page: Page) => page.getByPlaceholder('Search Products by Name'), + productCheckbox: (page: Page, productName: string) => + page.getByRole('checkbox', { name: productName, exact: true }), + saveButton: (page: Page) => page.getByRole('button', { name: 'Save' }), +}; diff --git a/apps/pos-web/src/pages/tags/[tagId].tsx b/apps/pos-web/src/pages/tags/[tagId].tsx new file mode 100644 index 00000000..0eb2efce --- /dev/null +++ b/apps/pos-web/src/pages/tags/[tagId].tsx @@ -0,0 +1,35 @@ +import { ApiTagRepository } from '@gatherloop-pos/ui'; +import { + TagUpdate, + TagUpdateProps, +} from '@gatherloop-pos/ui/pos'; +import { GetServerSideProps } from 'next'; +import { QueryClient } from '@tanstack/react-query'; + +export const getServerSideProps: GetServerSideProps< + TagUpdateProps, + { tagId: string } +> = async (ctx) => { + const isLoggedIn = ctx.req.headers.cookie?.includes('Authorization'); + if (!isLoggedIn) { + return { + redirect: { + destination: '/auth/login', + permanent: false, + }, + }; + } + + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const tagId = parseInt(ctx.params?.tagId ?? ''); + const tag = await tagRepository.fetchTagById(tagId, { + headers: { Cookie: ctx.req.headers.cookie }, + }); + + return { + props: { tagUpdateParams: { tag, tagId } }, + }; +}; + +export default TagUpdate; diff --git a/apps/pos-web/src/pages/tags/[tagId]/assign.tsx b/apps/pos-web/src/pages/tags/[tagId]/assign.tsx new file mode 100644 index 00000000..00fabc3c --- /dev/null +++ b/apps/pos-web/src/pages/tags/[tagId]/assign.tsx @@ -0,0 +1,28 @@ +import { + TagAssignment, + TagAssignmentProps, +} from '@gatherloop-pos/ui/pos'; +import { GetServerSideProps } from 'next'; + +export const getServerSideProps: GetServerSideProps< + TagAssignmentProps, + { tagId: string } +> = async (ctx) => { + const isLoggedIn = ctx.req.headers.cookie?.includes('Authorization'); + if (!isLoggedIn) { + return { + redirect: { + destination: '/auth/login', + permanent: false, + }, + }; + } + + const tagId = parseInt(ctx.params?.tagId ?? ''); + + return { + props: { tagAssignmentParams: { tagId } }, + }; +}; + +export default TagAssignment; diff --git a/apps/pos-web/src/pages/tags/create.tsx b/apps/pos-web/src/pages/tags/create.tsx new file mode 100644 index 00000000..af07051c --- /dev/null +++ b/apps/pos-web/src/pages/tags/create.tsx @@ -0,0 +1,12 @@ +import { TagCreate } from '@gatherloop-pos/ui/pos'; +import { GetServerSideProps } from 'next'; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const isLoggedIn = ctx.req.headers.cookie?.includes('Authorization'); + return { + props: {}, + redirect: isLoggedIn ? undefined : { destination: '/auth/login' }, + }; +}; + +export default TagCreate; diff --git a/apps/pos-web/src/pages/tags/index.tsx b/apps/pos-web/src/pages/tags/index.tsx new file mode 100644 index 00000000..b9a90c59 --- /dev/null +++ b/apps/pos-web/src/pages/tags/index.tsx @@ -0,0 +1,33 @@ +import { ApiTagRepository } from '@gatherloop-pos/ui'; +import { + TagList, + TagListProps, +} from '@gatherloop-pos/ui/pos'; +import { GetServerSideProps } from 'next'; +import { QueryClient } from '@tanstack/react-query'; + +export const getServerSideProps: GetServerSideProps = async ( + ctx +) => { + const isLoggedIn = ctx.req.headers.cookie?.includes('Authorization'); + if (!isLoggedIn) { + return { + redirect: { + destination: '/auth/login', + permanent: false, + }, + }; + } + + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const tags = await tagRepository.fetchTagList({ + headers: { Cookie: ctx.req.headers.cookie }, + }); + + return { + props: { tagListParams: { tags } }, + }; +}; + +export default TagList; diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 7d60feac..4a74ddd8 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -79,6 +79,7 @@ export default defineConfig({ { text: 'Categories', link: '/catalog/categories' }, { text: 'Products', link: '/catalog/products' }, { text: 'Product Variants', link: '/catalog/variants' }, + { text: 'Tags', link: '/catalog/tags' }, { text: 'Materials', link: '/catalog/materials' }, ], }, diff --git a/docs-site/catalog/tags.md b/docs-site/catalog/tags.md new file mode 100644 index 00000000..1b3ed44e --- /dev/null +++ b/docs-site/catalog/tags.md @@ -0,0 +1,44 @@ +# Tags + +## What it does + +A tag is a short coloured label — "Best Seller", "New", "Spicy" — that staff attach to menu items so customers (and the counter) can spot them at a glance. Staff manage tags from a **Tags** screen in the POS sidebar, right next to [Categories](/catalog/categories): create a tag, give it a name, pick one of eight colours (red, orange, yellow, green, blue, purple, pink, gray), set a sort order, and choose whether it should **show as a section on the order app**. + +Tags are applied from the tag's side. Open a tag's row menu, choose **Assign products**, and tick the products it belongs to — grouped by category, searchable by name, saved with one button. A product with several [variants](/catalog/variants) expands into a checklist, so a tag can sit on the whole product or on a single variant: + +| Menu item | What staff tick | What it means | +|---|---|---| +| **Coffee Latte** (Hot, Iced) | the product | Both temperatures are a best seller; one card in the "Best Seller" section. | +| **Pancong** (Choco, Matcha, Vanilla, Ice Cream) | only the *Ice Cream* variant | Just that flavour is new; the "New" section shows a *Pancong · Ice Cream* card with its own photo and price. Pancong itself is not badged "New". | +| **Salted Caramel Macchiato** (one variant) | the product | The product is new. The single variant is never shown — nobody has to think about it. | + +A variant can also be tagged straight from its own form, and the variant form has an optional **Image URL** so a highlighted variant can show its own photo instead of the product's. + +## Why it matters + +The shop regularly has items it wants customers to notice first — a new Pancong topping, a seasonal drink, the coffee that sells the most. Before tags there was nowhere to say so: the order-app menu was category-only, and neither the counter nor the customer could tell a new item from an old one. + +Tags put that recommendation exactly where customers already look. A customer scanning the table QR code sees "New" and "Best Seller" strips above the regular menu, and the same badges on every card — without staff maintaining a second menu. Because a tag is a label rather than a copy of an item, removing "New" next month is one untick on the assignment screen, and nothing else in the catalog changes. + +## Screenshot + +![Tags screenshot](/screenshots/tags.png) + +## Key capabilities + +- **Small managed list** — create a tag once, reuse it on as many items as needed. Names are unique ignoring case; deleting a tag asks for confirmation, states how many variants carry it, and removes its assignments with it. +- **Product or single variant** — a tag applies to a whole product when every variant carries it, otherwise to the specific variants that do. A variant added later to a tagged product starts untagged, and the variant form pre-fills the product's current tags so staff make that call in the same screen. +- **Badges on cards, in both apps** — at most two coloured pills per card, then `+N`. A variant-level tag names the variant (`New · Ice Cream`). They show on POS product cards, including the picker used when ringing up a [transaction](/sales/transactions), and on order-app menu cards and the item detail sheet. +- **Highlight sections on the order app** — a tag marked *Show as section on order app* gets its own horizontal strip above the categories, ordered by the tag's sort order. Entries are newest-tagged first, and only items that can currently be ordered appear; a section with nothing to sell is hidden. Items still appear under their own category as well — sections duplicate, never move. +- **Variant cards pre-select their option** — tapping *Pancong · Ice Cream* opens the item with Ice Cream already chosen; the customer can still switch topping. +- **Tag chips filter the menu** — each non-empty highlighted tag also becomes a coloured chip after **Semua** and before the categories. Tapping it shows only that tag's items as a vertical list; it and the category chips are mutually exclusive, and **Semua** clears both. Sections are hidden while searching or filtering. +- **Merchandising only** — tags are not copied onto transactions, receipts, kitchen tickets or statistics, so changing them never rewrites history. + +## For engineers + +- Screens: `libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx`, `TagCreateScreen.tsx`, `TagUpdateScreen.tsx`, `TagAssignmentScreen.tsx`; components in `libs/ui/src/presentation/views/components/tags/` (`TagFormView`, `TagAssignmentList`, `TagBadge`, `ProductTagBadges`) +- Order app: `MenuHighlightSection`, `MenuHighlightCard` and `CategoryChipList` in `libs/ui/src/presentation/views/components/menu/`; sections and chip filtering are built by `buildTagHighlights` in `libs/ui/src/utils/` inside `MenuListHandler`, with `selectedTagId` held in `MenuListUsecase` +- Entities: `libs/ui/src/domain/entities/Tag.ts`; backend `apps/api/domain/tag_entity.go` — `ResolveProductTags` derives product scope from variant assignments on every product read, and the `variant_tags` table is the single source of truth +- API: `/tags` CRUD and `PUT /tags/{tagId}/variants` (replaces one tag's assignments, diffing so untouched rows keep their original tag time); tags ride on the existing `/public/products` and `/public/variants` responses, so the order app needs no new endpoint +- End-to-end coverage: `apps/pos-web-e2e/src/tags.spec.ts` and `apps/order-web-e2e/src/tags.spec.ts` +- Design: `docs/prd-product-tags.md` (decisions D1–D18 and the phased plan) diff --git a/docs-site/public/screenshots/README.md b/docs-site/public/screenshots/README.md index 561bbf9d..c5d2a46a 100644 --- a/docs-site/public/screenshots/README.md +++ b/docs-site/public/screenshots/README.md @@ -54,6 +54,7 @@ Use Playwright against the running app (`playwright` is already a workspace depe | `categories.png` | `/catalog/categories` | | `products.png` | `/catalog/products` | | `variants.png` | `/catalog/variants` (Product detail → **Variants** tab) | +| `tags.png` | `/catalog/tags` (POS `/tags`; seed a highlighted "Best Seller" and "New" tag plus one non-highlighted tag, each assigned to at least one variant) | | `materials.png` | `/catalog/materials` | | `stock-checks.png` | `/inventory/stock-checks` | | `purchase-list.png` | `/inventory/purchase-lists` | diff --git a/docs-site/public/screenshots/tags.png b/docs-site/public/screenshots/tags.png new file mode 100644 index 00000000..fe68b6d1 Binary files /dev/null and b/docs-site/public/screenshots/tags.png differ diff --git a/docs/prd-product-tags.md b/docs/prd-product-tags.md new file mode 100644 index 00000000..b654ceae --- /dev/null +++ b/docs/prd-product-tags.md @@ -0,0 +1,736 @@ +# PRD: Product Tags — "Best Seller", "New", and Highlighted Menu Sections + +## Problem Statement + +The shop regularly has menu items it wants customers to notice first — a new Pancong topping, a +new seasonal drink, the coffee that sells the most. Today there is no way to say so anywhere in +the system: + +- **No tag concept exists.** A `Product` (`apps/api/domain/product_entity.go`) carries a single + `Category`, a `Status` (draft/published) and availability fields; a `Variant` + (`apps/api/domain/variant_entity.go`) carries price, materials, option values and availability. + Nothing can mark either as "new" or "best seller". +- **The order app menu is category-only.** `MenuListHandler.groupByCategory` + (`libs/ui/src/presentation/handlers/order/MenuListHandler.tsx`) turns the fetched products into + one group per category, and `MenuListScreen` renders those groups top to bottom. The customer + has no recommendation and cannot tell which items are new. +- **The product cards have nowhere to say it.** `MenuProductCard` + (`libs/ui/src/presentation/views/components/menu/MenuProductCard.tsx`) shows image, name, a + sold-out badge, search-match chips, description and starting price. The POS item picker's + `ProductListItem` (`libs/ui/src/presentation/views/components/products/ProductListItem.tsx`) + shows category, sale type, status and availability badges. Staff can't see it either. +- **A variant has no image.** `variants` (`000001_initial_schema.up.sql`) has no `image_url`; + only `products.image_url` exists. A "New: Pancong Ice Cream" card can only show the generic + Pancong photo. + +### Root cause + +"New" and "best seller" are facts about **what the customer can order**, and in this system what +the customer orders is a **variant** — the cart and the transaction both hold `variantId`, and a +product must have at least one variant to be sold at all. But the way people *talk* about these +facts is at whatever level is natural: "Coffee Latte is a best seller" (product), "the Ice Cream +Pancong is new" (one variant), "Salted Caramel Macchiato is new" (a product whose single +`Original` variant only exists because the system requires one). + +Any design has to store the fact at one level and *display* it at the level that is true. The +three cases from the brief are the test: + +| # | Item | Variants | What must be true on screen | +| --- | --- | --- | --- | +| 1 | **Pancong** | Choco, Matcha, Vanilla, **Ice Cream** | Pancong is **not** new. The Ice Cream variant **is** new, and the "New" section shows a *Pancong · Ice Cream* card with the Ice Cream image. | +| 2 | **Salted Caramel Macchiato** | Original (only one) | The **product** is new. The "New" section shows the product card, not a variant card. The admin never has to think about "Original". | +| 3 | **Coffee Latte** | Hot, Iced | The **product** is a best seller (both temperatures). The "Best Seller" section shows one Coffee Latte card, not two. | + +--- + +## How the Industry Handles This + +Observed patterns in food-ordering products (from product use, not independently re-verified for +this PRD — treat as background, not as a spec): + +1. **Delivery marketplaces put a curated/popular strip above the category menu.** GoFood, + GrabFood and ShopeeFood merchant pages open with a "Terlaris / Best seller / Popular" block + before the categories. Items in that block are *duplicated*, not moved — they still appear + under their own category. +2. **Badges are short, coloured pills on the item card**, usually one or two ("Baru", "Terlaris", + "Pedas"). More than two becomes noise. +3. **Merchant-side, tags are a small managed list** (Square Online item labels, Shopify product + tags/collections): the merchant creates the label once and applies it to many items. +4. **Marketplaces badge the orderable unit.** When one flavour of a dish is promoted, the + promoted card is that flavour, with its own photo and price. + +Pattern 1 and 4 together are exactly case 1: the highlighted card is the variant, the regular +menu still shows the product. + +--- + +## Alternatives Considered + +### Option A — Tags on products only ❌ + +`product_tags(product_id, tag_id)`. + +- ✅ Simplest possible model and UI; covers cases 2 and 3 directly. +- ❌ **Cannot express case 1.** Tagging Pancong "New" would claim Choco/Matcha/Vanilla are new — + the exact thing the brief says must not happen. Workaround (split Ice Cream into its own + product) breaks the option picker, availability counters and reporting. + +### Option B — Tags on products *and* variants (two join tables) ❌ + +`product_tags` means "the whole product, including future variants"; `variant_tags` means "only +this variant". + +- ✅ Stores the admin's words literally ("the product is new"). +- ❌ **Two sources of truth for one fact.** Coffee Latte could be tagged at product level *and* + Hot at variant level; every reader needs a precedence rule, and the admin UI needs to explain + the difference between "tag the product" and "tag all its variants" — which look identical today + and diverge silently later. +- ❌ **Inheritance is wrong half the time.** A product-level "New" inherited by a variant added + next month is right; a product-level "Best Seller" inherited by a brand-new Oat Milk variant is + a false claim shown to customers. + +### Option C — Tags on option values (e.g. the "Ice Cream" topping value) ❌ + +- ✅ Matches how the customer picks. +- ❌ Breaks for multi-option products — "Ice Cream" × "Large" is one variant, two option values; + which one carries "New"? +- ❌ Does nothing for cases 2 and 3 without a product-level table as well (→ Option B's problems). + +### Option D — Tags on variants only; product-level is *derived* ✅ **Recommended** + +One join table, `variant_tags(variant_id, tag_id)`. A product "has" a tag at product scope when +**every live variant** carries it; otherwise it has the tag at variant scope, naming which +variants. The derivation is a pure function on the server, exposed on every product read. + +- ✅ **One source of truth**, stored at the level the system actually sells. +- ✅ All three cases fall out with **no special-casing**: Ice Cream only → variant scope; one of + one → product scope; two of two → product scope (see the table below). +- ✅ **Never shows a claim nobody made.** A variant added later carries no tag until someone + gives it one (D4) — and the create-variant form pre-fills the product's current product-scope + tags so the admin makes that call consciously, in one glance. +- ✅ The admin UI can still *feel* product-level: a tri-state product checkbox ticks all variants, + and single-variant products never show their variant at all (D14). +- ⚠ Trade-off: "product scope" is a computed state, so adding an untagged variant to Coffee Latte + demotes it from one product card to per-variant cards in the Best Seller section. Accepted — + that is the truthful display, and D4's pre-fill makes it the admin's explicit choice. + +--- + +## Proposed Solution + +### High-level system design + +```mermaid +flowchart LR + subgraph POS["POS (pos-web / pos-mobile)"] + TM["Tag management
list · create · edit · delete"] + TA["Tag assignment
per tag: product ▸ variant checklist"] + VF["Variant form
+ image URL, + tags"] + PC1["Item picker / product list
tag badges"] + end + + subgraph API["apps/api (Go)"] + TagUC["TagUsecase
CRUD + SetTagVariants"] + Resolve["ResolveProductTags(variants)
pure domain fn"] + ProdUC["ProductUsecase / VariantUsecase
(existing reads)"] + end + + subgraph DB["MySQL"] + tags[("tags")] + vt[("variant_tags")] + variants[("variants
+ image_url")] + end + + subgraph ORDER["Order app (order-web)"] + HL["Highlight sections
(one per highlighted tag)"] + CAT["Category sections
(unchanged grouping)"] + PC2["Menu cards
tag badges"] + end + + TM --> TagUC + TA --> TagUC + VF --> ProdUC + TagUC --> tags + TagUC --> vt + ProdUC --> variants + ProdUC --> vt + ProdUC --> Resolve + Resolve -- "Product.tags[] (scope + variantIds)
Variant.tags[]" --> PC1 + Resolve -- "GET /public/products, /public/variants" --> HL + Resolve --> PC2 + HL --> PC2 + CAT --> PC2 +``` + +**In one paragraph:** admins manage a small list of **tags** (name, colour, "show as a section on +the order app", sort order) and attach them to **variants**. Every product read runs a pure +`ResolveProductTags` over the product's variants and returns `Product.tags[]`, each entry saying +whether the tag applies to the **whole product** or only to **specific variants**. Both apps +render badges from that one field. The order app additionally builds one **highlight section per +highlighted tag**, above the category sections, from data it already fetches — a product-scope +entry becomes a product card, a variant-scope entry becomes a card for that variant, with its own +image and price, which opens the detail sheet with that variant's options pre-selected. + +### Data model + +```mermaid +erDiagram + products ||--o{ variants : has + variants ||--o{ variant_tags : "tagged with" + tags ||--o{ variant_tags : "applied to" + + tags { + BIGINT id PK + VARCHAR name "unique, case-insensitive" + VARCHAR color "palette enum" + TINYINT is_highlighted "show section on order app" + INT sort_order "section + badge order" + DATETIME created_at + } + variant_tags { + BIGINT variant_id PK,FK + BIGINT tag_id PK,FK "ON DELETE CASCADE" + DATETIME created_at + } + variants { + VARCHAR image_url "NEW, nullable; falls back to product image" + } +``` + +| Table | Change | +| --- | --- | +| `tags` | **New.** `id`, `name VARCHAR(100) NOT NULL` + `UNIQUE (name)` (the default utf8mb4 collation makes it case-insensitive), `color VARCHAR(20) NOT NULL DEFAULT 'gray'`, `is_highlighted TINYINT(1) NOT NULL DEFAULT 0`, `sort_order INT NOT NULL DEFAULT 0`, `created_at`. | +| `variant_tags` | **New.** `PRIMARY KEY (variant_id, tag_id)`, `KEY (tag_id)`, FK to `variants`, FK to `tags` `ON DELETE CASCADE`, `created_at`. | +| `variants` | **New column** `image_url VARCHAR(255) NULL`. | + +### The three cases, expressed + +| Item | `variant_tags` rows | Resolved `Product.tags` | Order app "highlight" card | +| --- | --- | --- | --- | +| **Pancong** | (Ice Cream, New) | `[{ tag: New, scope: variant, variantIds: [IceCream] }]` | **Pancong · Ice Cream** — Ice Cream image, Ice Cream price; opens detail with *Ice Cream* pre-selected | +| **Salted Caramel Macchiato** | (Original, New) | `[{ tag: New, scope: product, variantIds: [Original] }]` | **Salted Caramel Macchiato** — product image, starting price | +| **Coffee Latte** | (Hot, Best Seller), (Iced, Best Seller) | `[{ tag: Best Seller, scope: product, variantIds: [Hot, Iced] }]` | **Coffee Latte** — one card, product image, starting price | + +And in the regular category list: + +| Item | Card badges | +| --- | --- | +| **Pancong** | `New · Ice Cream` (variant-scope pill naming the variant) — Pancong itself is *not* badged "New" | +| **Salted Caramel Macchiato** | `New` | +| **Coffee Latte** | `Best Seller` | + +### Resolution rule + +``` +ResolveProductTags(variants []Variant) []ProductTag + live := variants where DeletedAt == nil + for each tag T appearing on any live variant: + tagged := live variants carrying T + scope := "product" if len(tagged) == len(live) else "variant" + emit { Tag: T, Scope: scope, VariantIds: ids(tagged), + TaggedAt: max(variant_tags.created_at over tagged) } + sort by T.SortOrder, then T.Name +``` + +Coverage counts **live** variants, not **sellable** ones (D2): Coffee Latte stays a product-scope +best seller while Iced is sold out. + +--- + +## Feature Requirements + +### FR-1 — Tag management (POS) + +A **Tags** entry in the POS sidebar, beside Categories, opens a list screen with create, edit and +delete, shaped exactly like the existing Category screens (`CategoryListScreen`, +`CategoryCreateScreen`, `CategoryUpdateScreen`). Fields: + +| Field | Input | Rule | +| --- | --- | --- | +| Name | text | required, 1–100 chars, unique ignoring case | +| Colour | swatch picker | one of `red, orange, yellow, green, blue, purple, pink, gray` (D5) | +| Show as section on order app | switch | `is_highlighted` | +| Sort order | number | lower first; orders both sections and badges | + +Delete asks for confirmation and states how many variants carry the tag; it removes the tag and +its assignments (D9). + +API: `GET /tags`, `POST /tags`, `GET /tags/{tagId}`, `PUT /tags/{tagId}`, `DELETE /tags/{tagId}`, +all under `CheckAuth`. `Tag` schema: `id, name, color, isHighlighted, sortOrder, createdAt`. + +### FR-2 — Tag assignment (POS) + +From a tag's row, **Assign products** opens one screen per tag: every product, grouped by +category, searchable, with a checkbox per product and — for products with ≥ 2 variants — an +expandable checklist of its variants (D14). + +- The product checkbox is **tri-state**: checked (all live variants tagged), indeterminate (some), + unchecked (none). Ticking it ticks every variant; unticking clears them. +- A **single-variant product shows only the product checkbox** — the admin tags "Salted Caramel + Macchiato", never "Original". +- One **Save** sends the full set: `PUT /tags/{tagId}/variants` with `{ variantIds: [...] }`, + replacing that tag's assignments (D10). + +Data for the screen comes from the existing `GET /variants` (no `limit` = all rows, +`GetLimit` in `apps/api/presentation/restapi/base_transformers.go`), each variant now carrying +`tags` and its `product`; the screen groups by product client-side. + +### FR-3 — Variant form: image and tags (POS) + +`VariantFormView` gains: + +- **Image URL** (optional) — same text field as the product form's `imageUrl` + (`ProductFormView.tsx:126`). Empty means "use the product image". +- **Tags** — multi-select of all tags. On **create**, pre-filled with the tags that are currently + **product-scope** on the parent product (D4), so adding "Oat Milk" to a best-selling Coffee Latte + asks the admin, visibly, whether it is a best seller too. + +`VariantRequest` gains optional `imageUrl` and `tagIds`; **omitted `tagIds` leaves assignments +alone** (D10), so older clients and other edit paths never wipe tags. + +### FR-4 — Tags on every product and variant read (API) + +| Schema | New field | +| --- | --- | +| `Variant` | `tags: VariantTag[]` where `VariantTag = { tag: Tag, taggedAt: date-time }`, `imageUrl?: string` | +| `Product` | `tags: ProductTag[]` where `ProductTag = { tag: Tag, scope: 'product' \| 'variant', variantIds: int64[], taggedAt: date-time }` | + +`taggedAt` is `variant_tags.created_at` — for a `ProductTag`, the most recent one among its tagged +variants. It orders highlight entries newest first (D17). + +Populated on the authenticated and the public endpoints alike, computed by +`ResolveProductTags` (D3) at the same point `ProductUsecase.resolveAvailability` +(`apps/api/domain/product_usecase.go:16`) already loads the product's variants — the variant query +preloads `Tags`, adding no extra round trip per product. + +### FR-5 — Badges on product cards (POS and order app) + +One shared `TagBadge` component (coloured pill, colour from the tag's palette value, readable in +light and dark). Rules, identical in both apps: + +- Product-scope tags → pill with the tag name (`Best Seller`). +- Variant-scope tags → pill with tag and variant name (`New · Ice Cream`); if more than one + variant, `New · 2 varian`. +- At most **2** pills, in tag sort order, then `+N` (D12). +- Placement: POS `ProductListItem` subtitle (next to the sold-out/remaining badges — used by both + the product list and the transaction item picker); order app `MenuProductCard` under the name. +- In the order app detail sheet, once a variant resolves, its own tags show beside the price. + +### FR-6 — Highlight sections on the order app menu + +Above the category groups, one section per tag with `isHighlighted = true` that has at least one +entry, ordered by `sortOrder`: + +- **Header**: tag name (e.g. "Best Seller", "New"). +- **Body**: a horizontal carousel of compact cards (image on top, name, optional variant name, + price), so two sections don't push the regular menu off the first screen (D7). +- **Entries**, built from the `products` and `variants` the menu already loads + (`ApiMenuRepository.fetchMenu`), no new endpoint (D15): + - product-scope → one **product card**: product image, product name, starting price; + - variant-scope → one card **per tagged variant**: variant image (fallback: product image), + "Pancong" + "Ice Cream", that variant's price. +- **Entries are ordered most recently tagged first** (`taggedAt` descending, then menu order), + with **no per-section cap** — the carousel scrolls (D17, D18). +- **Only sellable entries** appear (D8); an empty section is not rendered. +- **Tapping** a product card opens the detail sheet as today; tapping a variant card opens it + with that variant's option values pre-selected — reusing `preselectedOptionValueIds` on + `menuItemDetail`'s `SELECT_PRODUCT` action (D6). +- Sections render **only when the search box is empty and no category or tag chip is + selected** (D7). Items still appear in their normal category below — sections duplicate, never + move. + +### FR-7 — Highlighted tags as chips on the order app menu + +`CategoryChipList` gains one chip per highlighted tag that has entries, placed after **Semua** and +before the category chips, in tag `sortOrder`, rendered in the tag's colour so they read as +different from categories (D16). + +- Tapping a tag chip **filters** the menu to that tag's entries, exactly the way a category chip + filters to its category today (`selectedCategoryId` in `MenuListUsecase`); the entries render + as a vertical list of full-width cards, in the same order as the section (D17). +- A tag chip and a category chip are **mutually exclusive**: selecting one clears the other. + **Semua** clears both and brings the highlight sections back. +- A tag chip only exists while its section would have at least one entry, so it never leads to an + empty list. + +--- + +## Design decisions + +**D1 — Tags attach to variants; product scope is derived.** A single `variant_tags` table; a +tag is product-scope for a product when every live variant carries it. *Alternative rejected:* +product + variant tag tables (Option B) — two sources of truth and an inheritance rule that is +wrong for "Best Seller". + +**D2 — Coverage is computed over live (non-deleted) variants, not sellable ones.** Availability +changes by the hour; a sold-out Iced must not flip Coffee Latte from one product card to a lone +"Hot" variant card. Sold-out is shown by the existing availability badges instead. + +**D3 — Resolution runs server-side in a pure domain function.** `ResolveProductTags` lives in +`apps/api/domain/tag_entity.go` next to the model, like `ResolveProductAvailability`, is +table-tested there, and is exposed on every product read. POS and the order app both get the +same answer without re-implementing the rule or fetching variants just to draw a badge. +*Alternative rejected:* resolve in `libs/ui` — the POS product list doesn't load variants, and two +implementations would drift. + +**D4 — New variants inherit nothing; the create form pre-fills instead.** Storage never infers a +claim. The create-variant form pre-selects the parent's current product-scope tags so the admin +confirms or clears them in the same form. This keeps "New" right (the admin leaves it ticked) and +"Best Seller" right (the admin unticks it). + +**D5 — Colour is a fixed palette, not free hex.** Eight named colours mapped to Tamagui `$5` +background / `$11` text tokens — the same pairing `SoldOutBadge` uses — so every tag is +legible in light and dark themes and nobody ships yellow-on-white. + +**D6 — A variant highlight card opens the product, pre-selected.** `MenuListUsecase`'s +`SELECT_ITEM` gains an optional `variantId`; `MenuListHandler` translates it into the variant's +option value ids and passes them as `preselectedOptionValueIds` to `SELECT_PRODUCT`, which already +exists for search matches. The customer lands on "Pancong, Ice Cream selected" and can still switch +topping. The URL keeps only `selectedProductId`, so a shared link opens the product without +pre-selection — acceptable. + +**D7 — Sections sit above categories, only in the default view, as carousels.** Search results and +a category filter are the customer asking for something specific; recommendations would get in the +way. Horizontal carousels keep the first screen from being all highlights. + +**D8 — Non-sellable entries are hidden from highlight sections.** Recommending something the +customer cannot order is worse than not recommending. They keep their badge in the category list, +where they already render disabled. + +**D9 — Tags are hard-deleted, assignments cascade.** Unlike categories (soft-deleted because +products point at them historically), nothing historical references a tag — transactions store +variants, not tags (D11). `ON DELETE CASCADE` on `variant_tags.tag_id` keeps deletion one +statement. + +**D10 — Two write paths, both explicit about scope.** `PUT /tags/{tagId}/variants` replaces the +full set for one tag (what the assignment screen edits). `VariantRequest.tagIds` replaces the set +for one variant, and **omitted means unchanged** — the same rule as availability's partial update +(`docs/prd-product-availability.md` D10), so no existing caller can wipe tags by not knowing about +them. + +**D11 — Tags are merchandising only.** Not snapshotted onto cart items or transactions, not on +receipts, KDS tickets or statistics. Removing "New" next month rewrites nothing. + +**D12 — At most two badges per card, then `+N`.** Beyond two, badges crowd out name and price on a +phone-width card. + +**D13 — Single-variant products need no special code.** One of one tagged is 100 % coverage → +product scope. Case 2 is a consequence of D1, not a branch. + +**D14 — The assignment screen hides the variant layer when it adds nothing.** Single-variant +products show only a product checkbox; multi-variant products show a tri-state product checkbox +with an expandable variant list. The admin works in product terms until a variant genuinely +differs. + +**D15 — No new public endpoint.** Tags ride on the existing `/public/products` and +`/public/variants` responses (with `isHighlighted` and `sortOrder` on the embedded `Tag`), and the +order app already holds both lists in `MenuListUsecase`'s context — highlight sections are a pure +function of state the handler already has. + +**D16 — Highlighted tags are also chips, and chips filter.** One chip per non-empty highlighted +tag, between **Semua** and the categories, in the tag's colour. A tag chip filters like a category +chip already does (`selectedCategoryId` filters `visibleGroups` in `MenuListHandler`; nothing on +the menu scrolls to an anchor today) — `MenuListUsecase` gains `selectedTagId`, mutually exclusive +with `selectedCategoryId`. *Alternative rejected:* scroll-to-section — it needs per-section layout +refs in a `ScrollView` the screen does not track today, and behaves differently from the category +chips right next to it. + +**D17 — Highlight entries are ordered most recently tagged first.** `variant_tags.created_at` is +exposed as `taggedAt` on `VariantTag` and `ProductTag` (latest across the product's tagged +variants); `buildTagHighlights` sorts by it descending, then by menu order. This is only true if +`created_at` means *when this variant got this tag*, so **both write paths diff instead of +delete-and-reinsert**: `PUT /tags/{tagId}/variants` and `VariantRequest.tagIds` insert only the +new pairs and delete only the removed ones, leaving untouched rows' `created_at` alone. Re-saving +the assignment screen must never make every item "newest". Untagging and re-tagging a variant does +restamp it — that is a deliberate re-promotion. + +**D18 — No per-section cap.** The horizontal carousel (D7) already bounds the vertical space a +section takes, however many entries it has; the tag chip (D16) gives the full list. A cap would +hide tagged items with no way to reach them from the section. + +--- + +## Settled in review + +Answers to the PRD's original open questions, recorded here rather than silently edited in: + +| # | Question | Answer | Recorded as | +| --- | --- | --- | --- | +| 1 | Should highlighted tags also appear as chips in `CategoryChipList`? | **Yes.** Implemented as a filter, matching how category chips behave. | FR-7, D16, Phase 13 | +| 2 | Menu order, or most recently tagged first? | **Most recently tagged first.** | FR-4 `taggedAt`, FR-6, D17, Phases 3, 4, 9 | +| 3 | Cap per section, or is the carousel enough? | **Carousel is enough** — no cap. | FR-6, D18 | + +--- + +## Phased plan + +Each phase is one PR, leaves `main` green and the product shippable on its own, and names its own +acceptance check. + +| # | Phase | Layer | Depends on | +| --- | --- | --- | --- | +| 1 | Tag CRUD API (`tags` table) | API | — | +| 2 | Variant image, end to end | API + POS | — | +| 3 | Variant ↔ tag assignment API (`variant_tags`) | API | 1 | +| 4 | Product tag resolution on reads | API | 3 | +| 5 | Frontend tag slice: entity, repository, CRUD use cases | libs/ui | 1 | +| 6 | POS Tag management screens + sidebar entry | POS | 5 | +| 7 | POS Tag assignment screen | POS | 3, 6 | +| 8 | POS variant form: tag multi-select with pre-fill | POS | 3, 5 | +| 9 | Frontend: tags on Product/Variant, `TagBadge`, `buildTagHighlights` | libs/ui | 4, 5 | +| 10 | POS product cards show tag badges | POS | 9 | +| 11 | Order app menu cards show tag badges | order | 9 | +| 12 | Order app highlight sections | order | 2, 9 | +| 13 | Order app highlighted-tag chips | order | 12 | +| 14 | Docs site page + e2e coverage | docs, e2e | 7, 10, 11, 13 | + +### What can run in parallel + +```mermaid +flowchart LR + P1["1 · Tag CRUD API"] --> P3["3 · Assignment API"] + P1 --> P5["5 · FE tag slice"] + P3 --> P4["4 · Product tag resolution"] + P3 --> P7 + P3 --> P8["8 · Variant form tags"] + P5 --> P6["6 · POS Tag screens"] + P5 --> P8 + P5 --> P9 + P6 --> P7["7 · POS assignment screen"] + P4 --> P9["9 · FE tags + TagBadge + highlights util"] + P9 --> P10["10 · POS badges"] + P9 --> P11["11 · Order badges"] + P9 --> P12["12 · Order highlight sections"] + P2["2 · Variant image"] --> P12 + P12 --> P13["13 · Order tag chips"] + P7 --> P14["14 · Docs + e2e"] + P10 --> P14 + P11 --> P14 + P13 --> P14 +``` + +| Wave | Phases that can be open at the same time | +| --- | --- | +| 1 | **1**, **2** | +| 2 | **3**, **5** | +| 3 | **4**, **6**, **8** | +| 4 | **7**, **9** | +| 5 | **10**, **11**, **12** | +| 6 | **13** (10 and 11 may still be open) | +| 7 | **14** | + +Phase 2 is fully independent and can land any time before 12. Admins can create tags after 6 and +tag products after 7 (or 8) — **before any customer sees anything**. Nothing customer-visible +changes until 11–13, so the shop can pre-load "New" and "Best Seller" and reveal them in one +deploy. + +> Every phase that touches `libs/api-contract/src/api.yaml` regenerates both clients +> (`npx nx run api-contract:generate:go`, `npx nx run api-contract:generate:ts`) and may need the +> new symbol added to `libs/ui/src/__mocks__/api-contract.ts`, which Jest substitutes wholesale +> for the generated package. + +--- + +### Phase 1 — Tag CRUD API + +Migration `000045_create_tags` (next free number; `000044_add_payment_verification` is the latest) +creating `tags` as in the data model, with a `down` that drops it. `Tag` entity and `TagColor` +string enum in `apps/api/domain/tag_entity.go`; `TagRepository` (`//go:generate mockgen`), +`TagUsecase` returning `(T, *domain.Error)` with a `BadRequest` on a duplicate name and an +unknown colour; MySQL repo/entity/transformer in `apps/api/data/mysql/`; handler, transformer and +routes under `CheckAuth` in `apps/api/presentation/restapi/`. Contract: `/tags`, `/tags/{tagId}`, +`Tag`, `TagRequest`, list/find/create/update responses. + +**Acceptance:** `tag_usecase_test.go` covers create, duplicate name differing only in case +(rejected), unknown colour (rejected), update, delete; `npx nx run api:test` green; +`MIGRATIONS_DIR=data/mysql/migrations make migrate-up && make migrate-down` clean both ways. + +### Phase 2 — Variant image, end to end + +Migration `000046_add_variant_image_url` (renumber if phase 3 lands first) adding +`variants.image_url VARCHAR(255) NULL`. `ImageUrl *string` on the Go `Variant` and its MySQL +entity/transformer; `imageUrl` on `Variant` and `VariantRequest` in `api.yaml`; `imageUrl` on +`libs/ui/src/domain/entities/Variant.ts`, `variant.transformer.ts` and the variant form schema; +an optional **Image URL** field in `VariantFormView`. No reader uses it yet. + +**Acceptance:** a variant round-trips through `PUT /variants/{id}` with and without `imageUrl`; +`VariantFormView` story shows the field; `npx nx run api:test` and `npx nx run ui:test` green. + +### Phase 3 — Variant ↔ tag assignment API + +Migration creating `variant_tags` as in the data model. `Tags []VariantTag` (tag + `TaggedAt`) on +the Go `Variant`, preloaded by every variant read in `variant_repo.go`. +`TagUsecase.SetTagVariants(ctx, tagId, variantIds)` replacing the set in one DB transaction by +**diffing** — insert added pairs, delete removed pairs, leave the rest (D17) — rejecting unknown or +deleted variant ids; `PUT /tags/{tagId}/variants`. `VariantRequest.tagIds` optional — +`VariantUsecase.CreateVariant` / `UpdateVariantById` apply the same diff only when it is present +(D10). `Tag.variantCount` on the tag list response for the delete confirmation. + +**Acceptance:** `tag_usecase_test.go` covers replace, empty set, unknown variant id (rejected); +`variant_usecase_test.go` covers update with `tagIds` omitted (tags untouched), empty (cleared), +and a list (replaced); a repo test shows re-saving an unchanged set keeps every row's +`created_at`; deleting a tag removes its `variant_tags` rows; `npx nx run api:test` green. + +### Phase 4 — Product tag resolution on reads + +`ProductTag` + `ResolveProductTags(variants []Variant) []ProductTag` in `tag_entity.go`, pure, +with `tag_entity_test.go` table-driving the three cases (Pancong, Salted Caramel, Coffee Latte), +a deleted variant ignored for coverage, a sold-out variant still counted (D2), `taggedAt` taking +the latest of the tagged variants (D17), and sort order. +Called from `ProductUsecase.resolveAvailability` (rename to `resolveDerivedFields`) where the +variants are already loaded; `tags: ProductTag[]` on the `Product` schema for authenticated and +public endpoints. + +**Acceptance:** the three-case table is a test; `GET /public/products` returns `tags: []` for +every untagged product and the expected scope for a seeded tagged one; `npx nx run api:test` green. + +### Phase 5 — Frontend tag slice + +No UI. `Tag` entity and `tagFormSchema` in `libs/ui/src/domain/entities/Tag.ts`; +`TagRepository` in `domain/repositories/`, implemented in `data/api/tag.ts` (+ transformer) and +`data/mock/tag.ts`; `TagListUsecase`, `TagCreateUsecase`, `TagUpdateUsecase`, `TagDeleteUsecase` +as `extends Usecase` state machines mirroring the `category*` use cases, +each with a `.test.ts` using `UsecaseTester` + `flushPromises` and +`MockTagRepository.setShouldFail(true)` for the error branch. Barrel exports. + +**Acceptance:** `npx nx run ui:test` green; use cases importable from `@gatherloop-pos/ui/pos`. + +### Phase 6 — POS Tag management screens + +`TagListScreen`, `TagCreateScreen`, `TagUpdateScreen` + `TagFormView` (owns `useForm` and the zod +resolver, `docs/forms.md`), `TagListItem` (shows colour pill, highlighted flag, variant count), +`TagDeleteAlert`; `TagListHandler` / `TagCreateHandler` / `TagUpdateHandler`; composition roots in +`libs/ui/src/app/pos/`; pages under `apps/pos-web/src/pages/tags/`; a **Tags** sidebar entry next +to Categories. Stories and handler tests for each screen. + +**Acceptance:** handler tests with real use cases over `MockTagRepository` cover create → list, +edit, delete-with-confirm; Storybook renders loaded/empty/error; `npx nx affected -t test lint` +green. + +### Phase 7 — POS Tag assignment screen + +`TagAssignmentUsecase` (load all variants via `VariantRepository`, `TOGGLE_VARIANT`, +`TOGGLE_PRODUCT` with tri-state semantics, `SEARCH`, `SAVE` → `TagRepository.setTagVariants`), +`TagAssignmentScreen` grouped by category → product → variants, single-variant products collapsed +to one checkbox (D14); `TagAssignmentHandler`; page `apps/pos-web/src/pages/tags/[tagId]/assign`; +"Assign products" action on `TagListItem`. + +**Acceptance:** use case test covers ticking a product (all variants), unticking one variant +(indeterminate), a single-variant product, and save sending exactly the ticked ids; handler test +asserts the checkbox states by accessible role; story for the Pancong/Salted Caramel/Coffee Latte +fixture. + +### Phase 8 — POS variant form: tag multi-select + +`tagIds` in the variant form schema and a multi-select in `VariantFormView`; `VariantCreate` / +`VariantUpdate` handlers load tags via `TagListUsecase`; on create, pre-fill with the parent +product's product-scope tags (D4) — the product's `tags` are already on the product the create +screen fetches once phase 4 is in; before that, pre-fill is empty and the phase still ships. + +**Acceptance:** handler test: creating a variant under a product with a product-scope "Best +Seller" shows it pre-ticked; unticking and saving sends `tagIds: []`; updating without touching +the field sends the existing ids. + +### Phase 9 — Frontend: tags on Product/Variant, `TagBadge`, highlight builder + +`tags: ProductTag[]` on `Product`, `tags: VariantTag[]` on `Variant`, and their transformers. +`TagBadge` component in `presentation/views/components/tags/` (palette → token map, D5) with +stories for every colour in both themes. `buildTagHighlights(products, variants)` in +`libs/ui/src/utils/` → `{ tag, entries: ({ kind: 'product', product } | { kind: 'variant', +product, variant })[] }[]`, filtering to highlighted tags and sellable entries (D8); sections ordered by tag `sortOrder`, +entries within a section by `taggedAt` descending, then menu order (D17). No screen changes. + +**Acceptance:** `buildTagHighlights.test.ts` covers the three cases, a non-highlighted tag +(no section), a sold-out variant entry (dropped), an empty section (dropped), and a newer tagged +variant sorting ahead of an older tagged product; `npx nx run ui:test` green. + +### Phase 10 — POS product cards show tag badges + +`ProductListItem` gains an optional `tags: ProductTag[]` prop rendered with `TagBadge` per FR-5 +(max 2 + `+N`, variant-scope pill naming the variant). Passed from `ProductList` and +`TransactionItemSelect`. + +**Acceptance:** `ProductListItem` stories for product-scope, variant-scope and overflow; the +transaction item picker story shows badges; `npx nx run ui:test` green. + +### Phase 11 — Order app menu cards show tag badges + +`MenuProductCard` renders `product.tags` with `TagBadge` under the name, per FR-5. +`MenuItemDetailScreen` shows the resolved variant's `tags` beside the price. + +**Acceptance:** `MenuProductCard` stories for the three cases (Pancong shows `New · Ice Cream`, not +`New`); `MenuListHandler.test.tsx` asserts the badge text for a tagged product. + +### Phase 12 — Order app highlight sections + +`MenuHighlightSection` + `MenuHighlightCard` components (horizontal carousel, variant image → +product image fallback, variant price or starting price). `MenuListScreen` gains a +`highlightSections` prop rendered above the category groups. `MenuListHandler` builds it with +`buildTagHighlights` only when `query` is empty and `selectedCategoryId` is `null` (D7). +`MenuListUsecase`'s `SELECT_ITEM` gains optional `variantId` kept in context; the handler maps it +to that variant's option value ids for `SELECT_PRODUCT.preselectedOptionValueIds` (D6). + +**Acceptance:** `menuList.test.ts` covers `SELECT_ITEM` with and without `variantId`; +`MenuListHandler.test.tsx` covers: sections rendered in `sortOrder` above categories, hidden while +searching or filtering, entries newest-tagged first, and tapping *Pancong · Ice Cream* opens the +sheet with Ice Cream pre-selected; `MenuListScreen` story with two sections. + +### Phase 13 — Order app highlighted-tag chips + +`CategoryChipList` gains an optional `tags` list rendered between **Semua** and the categories, +each chip in its tag colour, with `selectedTagId` / `onSelectTag` props. `MenuListUsecase` gains +`selectedTagId` in context and on `CHANGE_PARAMS`; the reducer clears `selectedCategoryId` when a +tag is selected and vice versa (D16). `MenuListHandler` passes only highlighted tags whose section +is non-empty, and when a tag is selected renders that tag's `buildTagHighlights` entries as a +vertical list of full-width `MenuHighlightCard`s instead of the category groups. +`MenuListScreen`'s `variant` gains a `{ type: 'tagLoaded'; tag; entries }` arm for that view. + +**Acceptance:** `menuList.test.ts` covers selecting a tag clears the category, selecting a category +clears the tag, and **Semua** clears both; `MenuListHandler.test.tsx` asserts the tag chip appears +only for a non-empty highlighted tag and that tapping it shows exactly that tag's entries, newest +first; `CategoryChipList` story with tag chips. + +### Phase 14 — Docs site page + e2e coverage + +A "Tags" page in `docs-site/` (managing tags, assigning to products vs. single variants, how the +order app shows them). `pos-web-e2e`: create a tag, assign it to a product, see the badge in the +transaction item picker. `order-web-e2e`: a highlighted tag's section appears above categories, a +variant card pre-selects its option, and the tag chip filters to that tag. Run locally — e2e runs +post-merge only. + +**Acceptance:** both e2e specs pass locally; docs site builds. + +--- + +## Risks + +| Risk | Mitigation | +| --- | --- | +| Adding an untagged variant to a product-scope-tagged product silently demotes it to per-variant cards. | D4 pre-fill makes it an explicit admin choice in the same form; the assignment screen shows the indeterminate state. | +| Stale "New" tags linger for months. | Manual removal is one untick on the assignment screen; auto-expiry is listed under Deferred. | +| Badge and section clutter if many tags are created. | Max 2 badges per card (D12); only `isHighlighted` tags get sections; carousels (D7). | +| Variant cards without an image look generic. | Fallback to the product image; the image field is in the variant form from phase 2. | +| Extra load on product list reads. | Tags are preloaded on the variant query `resolveAvailability` already runs — one batched preload, no new per-product query. | +| Public API now exposes tag names. | Only name, colour, highlight flag and sort order — nothing internal. Staff-only tags are Deferred. | + +## Out of Scope + +- Automatically computing "Best Seller" from transaction statistics. +- Tag-based discounts, coupons or pricing. +- Tags on receipts, KDS tickets, or transaction statistics (D11). +- Filtering the POS product list by tag. + +## Deferred + +- **Assignment expiry** (`variant_tags.expires_at`, e.g. "New for 30 days"). Worth it once "New" + tags demonstrably go stale; additive column, no model change. +- **Staff-only tags** (`tags.is_public`) for internal markers like "Seasonal stock". +- **Auto best seller**: a scheduled job writing `variant_tags` from sales rank — fits D1 unchanged, + because it only needs to write the same table. + +## Success Criteria + +- The three brief cases render exactly as in the "three cases" tables, in both apps. +- An admin can create a tag and apply it to a product without opening a variant form. +- Share of order-app orders containing at least one highlighted item, measured over the first + month after phase 12, is reported alongside the pre-launch baseline. diff --git a/libs/api-contract/src/api.yaml b/libs/api-contract/src/api.yaml index 36995745..23992cf5 100644 --- a/libs/api-contract/src/api.yaml +++ b/libs/api-contract/src/api.yaml @@ -587,6 +587,161 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /tags: + get: + summary: List all tags + operationId: tagList + tags: + - tag + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagListResponse' + '500': + description: server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Create tag + operationId: tagCreate + tags: + - tag + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagRequest' + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagCreateResponse' + '403': + description: validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /tags/{tagId}: + get: + summary: Get tag by id + operationId: tagFindById + tags: + - tag + parameters: + - $ref: '#/components/parameters/TagId' + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagFindByIdResponse' + '404': + description: not found error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + put: + summary: Update tag by id + operationId: tagUpdateById + tags: + - tag + parameters: + - $ref: '#/components/parameters/TagId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagRequest' + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagUpdateByIdResponse' + '404': + description: not found error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + summary: Delete tag by id + operationId: tagDeleteById + tags: + - tag + parameters: + - $ref: '#/components/parameters/TagId' + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '404': + description: not found error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /tags/{tagId}/variants: + put: + summary: Replace the variants a tag is applied to + operationId: tagSetVariants + tags: + - tag + parameters: + - $ref: '#/components/parameters/TagId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagVariantsRequest' + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagSetVariantsResponse' + '404': + description: not found error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /materials: get: summary: List all materials @@ -3333,6 +3488,127 @@ components: properties: data: $ref: '#/components/schemas/Category' + Tag: + type: object + required: + - id + - name + - color + - isHighlighted + - sortOrder + - createdAt + properties: + id: + type: integer + format: int64 + name: + type: string + color: + type: string + enum: [red, orange, yellow, green, blue, purple, pink, gray] + isHighlighted: + type: boolean + sortOrder: + type: integer + variantCount: + type: integer + format: int64 + createdAt: + type: string + format: date-time + VariantTag: + type: object + required: + - tag + - taggedAt + properties: + tag: + $ref: '#/components/schemas/Tag' + taggedAt: + type: string + format: date-time + ProductTag: + type: object + required: + - tag + - scope + - variantIds + - taggedAt + properties: + tag: + $ref: '#/components/schemas/Tag' + scope: + type: string + enum: [product, variant] + variantIds: + type: array + items: + type: integer + format: int64 + taggedAt: + type: string + format: date-time + TagVariantsRequest: + type: object + required: + - variantIds + properties: + variantIds: + type: array + items: + type: integer + format: int64 + TagSetVariantsResponse: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Tag' + TagRequest: + required: + - name + - color + - isHighlighted + - sortOrder + properties: + name: + type: string + color: + type: string + enum: [red, orange, yellow, green, blue, purple, pink, gray] + isHighlighted: + type: boolean + sortOrder: + type: integer + TagCreateResponse: + required: + - data + properties: + data: + $ref: '#/components/schemas/Tag' + TagUpdateByIdResponse: + required: + - data + properties: + data: + $ref: '#/components/schemas/Tag' + TagFindByIdResponse: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Tag' + TagListResponse: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/Tag' MaterialSupplier: type: object required: @@ -3852,6 +4128,7 @@ components: - isAvailable - availabilityTracking - isSellable + - tags properties: id: type: integer @@ -3898,6 +4175,10 @@ components: sellableQuantity: type: integer format: int64 + tags: + type: array + items: + $ref: '#/components/schemas/ProductTag' ProductRequest: type: object required: @@ -4009,6 +4290,7 @@ components: - pricingTiers - isAvailable - isSellable + - tags properties: id: type: integer @@ -4026,6 +4308,12 @@ components: type: string recipe: type: string + imageUrl: + type: string + tags: + type: array + items: + $ref: '#/components/schemas/VariantTag' materials: type: array items: @@ -4074,6 +4362,8 @@ components: type: string recipe: type: string + imageUrl: + type: string materials: type: array items: @@ -4086,6 +4376,11 @@ components: type: array items: $ref: '#/components/schemas/PricingTier' + tagIds: + type: array + items: + type: integer + format: int64 VariantMaterial: type: object required: @@ -6242,6 +6537,12 @@ components: schema: type: integer required: true + TagId: + name: tagId + in: path + schema: + type: integer + required: true MaterialId: name: materialId in: path diff --git a/libs/ui/.storybook/mocks/mockData.ts b/libs/ui/.storybook/mocks/mockData.ts index 8c96851b..9e491b0e 100644 --- a/libs/ui/.storybook/mocks/mockData.ts +++ b/libs/ui/.storybook/mocks/mockData.ts @@ -3,6 +3,7 @@ import type { Product, Material, Variant, + Tag, Wallet, Budget, Coupon, @@ -142,6 +143,7 @@ export const mockProduct: Product = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; export const mockProducts: Product[] = [ @@ -159,6 +161,7 @@ export const mockProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, { id: 3, @@ -173,6 +176,7 @@ export const mockProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, ]; @@ -207,6 +211,7 @@ export const mockVariant: Variant = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; export const mockVariants: Variant[] = [ @@ -226,6 +231,7 @@ export const mockVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, ]; @@ -526,6 +532,7 @@ const mockRentalVariant: Variant = { ], isAvailable: true, isSellable: true, + tags: [], }; export const mockRental: Rental = { @@ -575,3 +582,33 @@ export const mockSuppliers: Supplier[] = [ createdAt: '2024-01-16T08:00:00.000Z', }, ]; + +export const mockTags: Tag[] = [ + { + id: 1, + name: 'New', + color: 'green', + isHighlighted: true, + sortOrder: 1, + variantCount: 3, + createdAt: '2024-03-20T00:00:00.000Z', + }, + { + id: 2, + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 2, + variantCount: 5, + createdAt: '2024-03-21T00:00:00.000Z', + }, + { + id: 3, + name: 'Vegan', + color: 'blue', + isHighlighted: false, + sortOrder: 3, + variantCount: 1, + createdAt: '2024-03-22T00:00:00.000Z', + }, +]; diff --git a/libs/ui/src/__mocks__/tamagui.tsx b/libs/ui/src/__mocks__/tamagui.tsx index fc995c28..1a69a240 100644 --- a/libs/ui/src/__mocks__/tamagui.tsx +++ b/libs/ui/src/__mocks__/tamagui.tsx @@ -236,10 +236,21 @@ export const Dialog = Object.assign(DialogBase, { Close: DialogClose, }); -const CheckboxBase = ({ children, onCheckedChange, checked }: AnyProps) => +const CheckboxBase = ({ + children, + onCheckedChange, + checked, + accessibilityLabel, +}: AnyProps) => React.createElement( 'div', - { 'data-component': 'Checkbox', onClick: () => onCheckedChange?.(!checked) }, + { + 'data-component': 'Checkbox', + role: 'checkbox', + 'aria-checked': checked === 'indeterminate' ? 'mixed' : Boolean(checked), + ...(accessibilityLabel ? { 'aria-label': accessibilityLabel } : {}), + onClick: () => onCheckedChange?.(!checked), + }, children ); const CheckboxIndicator = ({ children }: AnyProps) => diff --git a/libs/ui/src/app/pos/TagAssignment.tsx b/libs/ui/src/app/pos/TagAssignment.tsx new file mode 100644 index 00000000..b9911eab --- /dev/null +++ b/libs/ui/src/app/pos/TagAssignment.tsx @@ -0,0 +1,37 @@ +import { + ApiAuthRepository, + ApiTagRepository, + ApiVariantRepository, +} from '../../data'; +import { + AuthLogoutUsecase, + TagAssignmentParams, + TagAssignmentUsecase, +} from '../../domain'; +import { TagAssignmentHandler } from '../../presentation'; +import { QueryClient } from '@tanstack/react-query'; + +export type TagAssignmentProps = { + tagAssignmentParams: TagAssignmentParams; +}; + +export function TagAssignment({ tagAssignmentParams }: TagAssignmentProps) { + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const variantRepository = new ApiVariantRepository(client); + const authRepository = new ApiAuthRepository(); + + const authLogoutUsecase = new AuthLogoutUsecase(authRepository); + const tagAssignmentUsecase = new TagAssignmentUsecase( + tagRepository, + variantRepository, + tagAssignmentParams + ); + + return ( + + ); +} diff --git a/libs/ui/src/app/pos/TagCreate.tsx b/libs/ui/src/app/pos/TagCreate.tsx new file mode 100644 index 00000000..022d6080 --- /dev/null +++ b/libs/ui/src/app/pos/TagCreate.tsx @@ -0,0 +1,20 @@ +import { ApiAuthRepository, ApiTagRepository } from '../../data'; +import { AuthLogoutUsecase, TagCreateUsecase } from '../../domain'; +import { TagCreateHandler } from '../../presentation'; +import { QueryClient } from '@tanstack/react-query'; + +export function TagCreate() { + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const authRepository = new ApiAuthRepository(); + + const tagCreateUsecase = new TagCreateUsecase(tagRepository); + const authLogoutUsecase = new AuthLogoutUsecase(authRepository); + + return ( + + ); +} diff --git a/libs/ui/src/app/pos/TagList.tsx b/libs/ui/src/app/pos/TagList.tsx new file mode 100644 index 00000000..32a3ff9e --- /dev/null +++ b/libs/ui/src/app/pos/TagList.tsx @@ -0,0 +1,34 @@ +import { ApiAuthRepository, ApiTagRepository } from '../../data'; +import { + TagListUsecase, + TagDeleteUsecase, + AuthLogoutUsecase, + TagListParams, +} from '../../domain'; +import { TagListHandler } from '../../presentation'; +import { QueryClient } from '@tanstack/react-query'; + +export type TagListProps = { + tagListParams: TagListParams; +}; + +export function TagList({ tagListParams }: TagListProps) { + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const authRepository = new ApiAuthRepository(); + + const authLogoutUsecase = new AuthLogoutUsecase(authRepository); + const tagDeleteUsecase = new TagDeleteUsecase(tagRepository); + const tagListUsecase = new TagListUsecase( + tagRepository, + tagListParams + ); + + return ( + + ); +} diff --git a/libs/ui/src/app/pos/TagUpdate.tsx b/libs/ui/src/app/pos/TagUpdate.tsx new file mode 100644 index 00000000..b52c2f7c --- /dev/null +++ b/libs/ui/src/app/pos/TagUpdate.tsx @@ -0,0 +1,33 @@ +import { ApiAuthRepository, ApiTagRepository } from '../../data'; +import { + AuthLogoutUsecase, + TagUpdateParams, + TagUpdateUsecase, +} from '../../domain'; +import { TagUpdateHandler } from '../../presentation'; +import { QueryClient } from '@tanstack/react-query'; + +export type TagUpdateProps = { + tagUpdateParams: TagUpdateParams; +}; + +export function TagUpdate({ + tagUpdateParams, +}: TagUpdateProps) { + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const authRepository = new ApiAuthRepository(); + + const authLogoutUsecase = new AuthLogoutUsecase(authRepository); + const tagUpdateUsecase = new TagUpdateUsecase( + tagRepository, + tagUpdateParams + ); + + return ( + + ); +} diff --git a/libs/ui/src/app/pos/VariantCreate.tsx b/libs/ui/src/app/pos/VariantCreate.tsx index e1e6ac91..352d0230 100644 --- a/libs/ui/src/app/pos/VariantCreate.tsx +++ b/libs/ui/src/app/pos/VariantCreate.tsx @@ -2,6 +2,7 @@ import { ApiAuthRepository, ApiMaterialRepository, ApiProductRepository, + ApiTagRepository, ApiVariantRepository, UrlMaterialListQueryRepository, } from '../../data'; @@ -9,6 +10,7 @@ import { AuthLogoutUsecase, MaterialListParams, MaterialListUsecase, + TagListUsecase, VariantCreateParams, VariantCreateUsecase, } from '../../domain'; @@ -29,6 +31,7 @@ export function VariantCreate({ const productRepository = new ApiProductRepository(client); const materialRepository = new ApiMaterialRepository(client); const materialListQueryRepository = new UrlMaterialListQueryRepository(); + const tagRepository = new ApiTagRepository(client); const authRepository = new ApiAuthRepository(); const materialListUsecase = new MaterialListUsecase( @@ -36,6 +39,7 @@ export function VariantCreate({ materialListQueryRepository, materialListParam ); + const tagListUsecase = new TagListUsecase(tagRepository, { tags: [] }); const authLogoutUsecase = new AuthLogoutUsecase(authRepository); const variantCreateUsecase = new VariantCreateUsecase( variantRepository, @@ -47,6 +51,7 @@ export function VariantCreate({ ); diff --git a/libs/ui/src/app/pos/VariantUpdate.tsx b/libs/ui/src/app/pos/VariantUpdate.tsx index 77715bff..1bb9c927 100644 --- a/libs/ui/src/app/pos/VariantUpdate.tsx +++ b/libs/ui/src/app/pos/VariantUpdate.tsx @@ -2,6 +2,7 @@ import { ApiAuthRepository, ApiMaterialRepository, ApiProductRepository, + ApiTagRepository, ApiVariantRepository, UrlMaterialListQueryRepository, } from '../../data'; @@ -9,6 +10,7 @@ import { AuthLogoutUsecase, MaterialListParams, MaterialListUsecase, + TagListUsecase, VariantUpdateParams, VariantUpdateUsecase, } from '../../domain'; @@ -29,8 +31,10 @@ export function VariantUpdate({ const productRepository = new ApiProductRepository(client); const materialRepository = new ApiMaterialRepository(client); const materialListQueryRepository = new UrlMaterialListQueryRepository(); + const tagRepository = new ApiTagRepository(client); const authRepository = new ApiAuthRepository(); + const tagListUsecase = new TagListUsecase(tagRepository, { tags: [] }); const authLogoutUsecase = new AuthLogoutUsecase(authRepository); const materialListUsecase = new MaterialListUsecase( materialRepository, @@ -47,6 +51,7 @@ export function VariantUpdate({ ); diff --git a/libs/ui/src/app/pos/index.ts b/libs/ui/src/app/pos/index.ts index 942df1a9..a9aa397b 100644 --- a/libs/ui/src/app/pos/index.ts +++ b/libs/ui/src/app/pos/index.ts @@ -14,6 +14,9 @@ export * from './MaterialUpdate'; export * from './CategoryList'; export * from './CategoryCreate'; export * from './CategoryUpdate'; +export * from './TagList'; +export * from './TagCreate'; +export * from './TagUpdate'; export * from './CouponList'; export * from './CouponCreate'; export * from './CouponUpdate'; @@ -55,3 +58,4 @@ export * from './StockCheckCreate'; export * from './StockCheckUpdate'; export * from './PurchaseList'; export * from './Availability'; +export * from './TagAssignment'; diff --git a/libs/ui/src/data/api/index.ts b/libs/ui/src/data/api/index.ts index aefdbfcc..e744752b 100644 --- a/libs/ui/src/data/api/index.ts +++ b/libs/ui/src/data/api/index.ts @@ -48,3 +48,5 @@ export * from './payment.transformer'; export * from './customer'; export * from './kdsDevice'; export * from './kdsDevice.transformer'; +export * from './tag'; +export * from './tag.transformer'; diff --git a/libs/ui/src/data/api/product.transformer.ts b/libs/ui/src/data/api/product.transformer.ts index 4ed7c3ca..0a0519f4 100644 --- a/libs/ui/src/data/api/product.transformer.ts +++ b/libs/ui/src/data/api/product.transformer.ts @@ -2,6 +2,7 @@ import { Product as ApiProduct } from '../../../../api-contract/src'; import { Product, ProductForm } from '../../domain'; import { toCategory } from './category.transformer'; +import { toProductTag } from './tag.transformer'; export function toProduct(product: ApiProduct): Product { return { @@ -20,6 +21,7 @@ export function toProduct(product: ApiProduct): Product { availableQuantity: product.availableQuantity, isSellable: product.isSellable, sellableQuantity: product.sellableQuantity, + tags: product.tags.map(toProductTag), }; } diff --git a/libs/ui/src/data/api/tag.transformer.test.ts b/libs/ui/src/data/api/tag.transformer.test.ts new file mode 100644 index 00000000..73da1865 --- /dev/null +++ b/libs/ui/src/data/api/tag.transformer.test.ts @@ -0,0 +1,75 @@ +import { toProduct } from './product.transformer'; +import { toVariant } from './variant.transformer'; + +const apiTag = { + id: 1, + name: 'New', + color: 'green' as const, + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const apiProduct = { + id: 1, + name: 'Pancong', + categoryId: 1, + category: { + id: 1, + name: 'Menu', + station: 'KITCHEN' as const, + createdAt: '2024-03-20T00:00:00.000Z', + }, + imageUrl: 'https://example.com/pancong.jpg', + createdAt: '2024-03-20T00:00:00.000Z', + options: [], + saleType: 'purchase' as const, + status: 'published' as const, + isAvailable: true, + availabilityTracking: 'none' as const, + isSellable: true, + tags: [ + { + tag: apiTag, + scope: 'variant' as const, + variantIds: [14], + taggedAt: '2024-03-01T00:00:00.000Z', + }, + ], +}; + +describe('tag transformers', () => { + it('maps product tags', () => { + expect(toProduct(apiProduct).tags).toEqual([ + { + tag: apiTag, + scope: 'variant', + variantIds: [14], + taggedAt: '2024-03-01T00:00:00.000Z', + }, + ]); + }); + + it('maps variant tags and the nested product tags', () => { + const variant = toVariant({ + id: 14, + name: 'Ice Cream', + price: 15000, + productId: 1, + product: apiProduct, + createdAt: '2024-03-20T00:00:00.000Z', + materials: [], + values: [], + pricingTiers: [], + isAvailable: true, + isSellable: true, + tags: [{ tag: apiTag, taggedAt: '2024-03-01T00:00:00.000Z' }], + } as unknown as Parameters[0]); + + expect(variant.tags).toEqual([ + { tag: apiTag, taggedAt: '2024-03-01T00:00:00.000Z' }, + ]); + expect(variant.product.tags).toHaveLength(1); + }); +}); diff --git a/libs/ui/src/data/api/tag.transformer.ts b/libs/ui/src/data/api/tag.transformer.ts new file mode 100644 index 00000000..32551bc5 --- /dev/null +++ b/libs/ui/src/data/api/tag.transformer.ts @@ -0,0 +1,36 @@ +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + Tag as ApiTag, + ProductTag as ApiProductTag, +} from '../../../../api-contract/src'; +import { ProductTag, Tag, TagForm } from '../../domain'; + +export function toTag(tag: ApiTag): Tag { + return { + id: tag.id, + name: tag.name, + color: tag.color, + isHighlighted: tag.isHighlighted, + sortOrder: tag.sortOrder, + variantCount: tag.variantCount ?? 0, + createdAt: tag.createdAt, + }; +} + +export function toProductTag(productTag: ApiProductTag): ProductTag { + return { + tag: toTag(productTag.tag), + scope: productTag.scope, + variantIds: productTag.variantIds, + taggedAt: productTag.taggedAt, + }; +} + +export function toApiTag(form: TagForm) { + return { + name: form.name, + color: form.color, + isHighlighted: form.isHighlighted, + sortOrder: form.sortOrder, + }; +} diff --git a/libs/ui/src/data/api/tag.ts b/libs/ui/src/data/api/tag.ts new file mode 100644 index 00000000..9fa4a668 --- /dev/null +++ b/libs/ui/src/data/api/tag.ts @@ -0,0 +1,57 @@ +import { QueryClient } from '@tanstack/react-query'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + tagCreate, + tagDeleteById, + tagFindById, + tagFindByIdQueryKey, + tagList, + tagListQueryKey, + tagSetVariants, + tagUpdateById, +} from '../../../../api-contract/src'; +import { Tag, TagRepository } from '../../domain'; +import { RequestConfig } from '@kubb/swagger-client/client'; +import { toApiTag, toTag } from './tag.transformer'; + +export class ApiTagRepository implements TagRepository { + client: QueryClient; + + constructor(client: QueryClient) { + this.client = client; + } + + fetchTagById = (tagId: number, options?: Partial) => { + return this.client + .fetchQuery({ + queryKey: tagFindByIdQueryKey(tagId), + queryFn: () => tagFindById(tagId, options), + }) + .then(({ data }) => toTag(data)); + }; + + createTag: TagRepository['createTag'] = (formValues) => { + return tagCreate(toApiTag(formValues)).then(); + }; + + updateTag: TagRepository['updateTag'] = (formValues, tagId) => { + return tagUpdateById(tagId, toApiTag(formValues)).then(); + }; + + setTagVariants: TagRepository['setTagVariants'] = (tagId, variantIds) => { + return tagSetVariants(tagId, { variantIds }).then(); + }; + + deleteTagById: TagRepository['deleteTagById'] = (tagId) => { + return tagDeleteById(tagId).then(); + }; + + fetchTagList = (options?: Partial): Promise => { + return this.client + .fetchQuery({ + queryKey: tagListQueryKey(), + queryFn: () => tagList(options), + }) + .then((data) => data.data.map(toTag)); + }; +} diff --git a/libs/ui/src/data/api/transaction.transformer.ts b/libs/ui/src/data/api/transaction.transformer.ts index 6dc9f49b..afe61d4f 100644 --- a/libs/ui/src/data/api/transaction.transformer.ts +++ b/libs/ui/src/data/api/transaction.transformer.ts @@ -10,6 +10,7 @@ import { TransactionStatistic, TransactionVerification, } from '../../domain'; +import { toProductTag, toTag } from './tag.transformer'; import { toWallet } from './wallet.transformer'; export function toTransactionStatistic( @@ -85,6 +86,7 @@ export function toTransaction(transaction: ApiTransaction): Transaction { availableQuantity: item.variant.product.availableQuantity, isSellable: item.variant.product.isSellable, sellableQuantity: item.variant.product.sellableQuantity, + tags: item.variant.product.tags.map(toProductTag), }, values: item.variant.values.map((value) => ({ id: value.id, @@ -105,6 +107,10 @@ export function toTransaction(transaction: ApiTransaction): Transaction { availableQuantity: item.variant.availableQuantity, isSellable: item.variant.isSellable, sellableQuantity: item.variant.sellableQuantity, + tags: item.variant.tags.map(({ tag, taggedAt }) => ({ + tag: toTag(tag), + taggedAt, + })), }, })), transactionCoupons: transaction.transactionCoupons.map((item) => ({ diff --git a/libs/ui/src/data/api/variant.transformer.ts b/libs/ui/src/data/api/variant.transformer.ts index db758947..360edb74 100644 --- a/libs/ui/src/data/api/variant.transformer.ts +++ b/libs/ui/src/data/api/variant.transformer.ts @@ -2,6 +2,7 @@ import { Variant as ApiVariant } from '../../../../api-contract/src'; import { Variant, VariantForm } from '../../domain'; import { toMaterial } from './material.transformer'; +import { toProductTag, toTag } from './tag.transformer'; export function toVariant(variant: ApiVariant): Variant { return { @@ -17,6 +18,7 @@ export function toVariant(variant: ApiVariant): Variant { })), description: variant.description ?? '', recipe: variant.recipe ?? '', + imageUrl: variant.imageUrl || undefined, product: { category: { createdAt: variant.product.category.createdAt, @@ -37,6 +39,7 @@ export function toVariant(variant: ApiVariant): Variant { availabilityTracking: variant.product.availabilityTracking, availableQuantity: variant.product.availableQuantity, isSellable: variant.product.isSellable, + tags: variant.product.tags.map(toProductTag), sellableQuantity: variant.product.sellableQuantity, }, values: variant.values.map((value) => ({ @@ -56,6 +59,10 @@ export function toVariant(variant: ApiVariant): Variant { availableQuantity: variant.availableQuantity, isSellable: variant.isSellable, sellableQuantity: variant.sellableQuantity, + tags: variant.tags.map(({ tag, taggedAt }) => ({ + tag: toTag(tag), + taggedAt, + })), }; } @@ -71,6 +78,7 @@ export function toApiVariant(form: VariantForm) { })), description: form.description, recipe: form.recipe, + imageUrl: form.imageUrl, values: form.values.map(({ id, optionValueId }) => ({ id, optionValueId, @@ -79,5 +87,6 @@ export function toApiVariant(form: VariantForm) { upToMinutes, price, })), + tagIds: form.tagIds, }; } diff --git a/libs/ui/src/data/mock/cart.ts b/libs/ui/src/data/mock/cart.ts index 4e07b989..b35cd32c 100644 --- a/libs/ui/src/data/mock/cart.ts +++ b/libs/ui/src/data/mock/cart.ts @@ -35,6 +35,7 @@ const product: Product = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; const variants: Record = { @@ -56,6 +57,7 @@ const variants: Record = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, 2: { id: 2, @@ -75,6 +77,7 @@ const variants: Record = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, }; diff --git a/libs/ui/src/data/mock/index.ts b/libs/ui/src/data/mock/index.ts index 3c04b36f..10d2917c 100644 --- a/libs/ui/src/data/mock/index.ts +++ b/libs/ui/src/data/mock/index.ts @@ -39,3 +39,4 @@ export * from './payment'; export * from './customer'; export * from './kdsDevice'; export * from './pushToken'; +export * from './tag'; diff --git a/libs/ui/src/data/mock/menu.ts b/libs/ui/src/data/mock/menu.ts index 6e34fb05..34331a18 100644 --- a/libs/ui/src/data/mock/menu.ts +++ b/libs/ui/src/data/mock/menu.ts @@ -40,6 +40,7 @@ const initialProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, { id: 2, @@ -54,6 +55,7 @@ const initialProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, ]; @@ -76,6 +78,7 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, { id: 2, @@ -95,6 +98,7 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, { id: 3, @@ -107,6 +111,7 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, ]; diff --git a/libs/ui/src/data/mock/product.ts b/libs/ui/src/data/mock/product.ts index 7a33a4f1..f06f95bd 100644 --- a/libs/ui/src/data/mock/product.ts +++ b/libs/ui/src/data/mock/product.ts @@ -23,6 +23,7 @@ const initialProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, { id: 2, @@ -45,6 +46,7 @@ const initialProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, ]; @@ -114,6 +116,7 @@ export class MockProductRepository implements ProductRepository { isAvailable: true, availabilityTracking: formValues.availabilityTracking, isSellable: true, + tags: [], }); } diff --git a/libs/ui/src/data/mock/rental.ts b/libs/ui/src/data/mock/rental.ts index d7061e52..e5c3825c 100644 --- a/libs/ui/src/data/mock/rental.ts +++ b/libs/ui/src/data/mock/rental.ts @@ -23,12 +23,14 @@ const mockVariant = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }, createdAt: '2024-03-20T00:00:00.000Z', values: [], pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const initialRentals: Rental[] = [ diff --git a/libs/ui/src/data/mock/tag.ts b/libs/ui/src/data/mock/tag.ts new file mode 100644 index 00000000..39f6c751 --- /dev/null +++ b/libs/ui/src/data/mock/tag.ts @@ -0,0 +1,97 @@ +import { Tag, TagForm } from '../../domain/entities/Tag'; +import { TagRepository } from '../../domain/repositories/tag'; + +export const mockTags: Tag[] = [ + { + id: 1, + name: 'New', + color: 'green', + isHighlighted: true, + sortOrder: 1, + variantCount: 3, + createdAt: '2024-03-20T00:00:00.000Z', + }, + { + id: 2, + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 2, + variantCount: 5, + createdAt: '2024-03-21T00:00:00.000Z', + }, +]; + +export class MockTagRepository implements TagRepository { + tags: Tag[] = [...mockTags]; + + lastSubmittedVariantIds: number[] | null = null; + + private nextId = 3; + private shouldFail = false; + + setShouldFail(value: boolean) { + this.shouldFail = value; + } + + async fetchTagList(): Promise { + if (this.shouldFail) { + throw new Error('Failed to fetch tags'); + } + return [...this.tags]; + } + + async fetchTagById(tagId: number): Promise { + if (this.shouldFail) { + throw new Error('Failed to fetch tag'); + } + const tag = this.tags.find((t) => t.id === tagId); + if (!tag) throw new Error('Tag not found'); + return { ...tag }; + } + + async deleteTagById(tagId: number): Promise { + if (this.shouldFail) { + throw new Error('Failed to delete tag'); + } + this.tags = this.tags.filter((t) => t.id !== tagId); + } + + async createTag(formValues: TagForm): Promise { + if (this.shouldFail) { + throw new Error('Failed to create tag'); + } + this.tags.push({ + id: this.nextId++, + ...formValues, + variantCount: 0, + createdAt: new Date().toISOString(), + }); + } + + async updateTag(formValues: TagForm, tagId: number): Promise { + if (this.shouldFail) { + throw new Error('Failed to update tag'); + } + const idx = this.tags.findIndex((t) => t.id === tagId); + if (idx === -1) throw new Error('Tag not found'); + this.tags[idx] = { ...this.tags[idx], ...formValues }; + } + + async setTagVariants(tagId: number, variantIds: number[]): Promise { + if (this.shouldFail) { + throw new Error('Failed to set tag variants'); + } + const idx = this.tags.findIndex((t) => t.id === tagId); + if (idx === -1) throw new Error('Tag not found'); + this.lastSubmittedVariantIds = variantIds; + this.tags[idx] = { ...this.tags[idx], variantCount: variantIds.length }; + } + + reset() { + this.tags = [...mockTags]; + this.lastSubmittedVariantIds = null; + this.nextId = 3; + this.shouldFail = false; + } +} diff --git a/libs/ui/src/data/mock/transaction.ts b/libs/ui/src/data/mock/transaction.ts index 5e70feee..4ccaef62 100644 --- a/libs/ui/src/data/mock/transaction.ts +++ b/libs/ui/src/data/mock/transaction.ts @@ -29,12 +29,14 @@ const mockVariant = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }, createdAt: '2024-03-20T00:00:00.000Z', values: [], pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const initialTransactions: Transaction[] = [ diff --git a/libs/ui/src/data/mock/variant.ts b/libs/ui/src/data/mock/variant.ts index 2d043bb6..d4a17a11 100644 --- a/libs/ui/src/data/mock/variant.ts +++ b/libs/ui/src/data/mock/variant.ts @@ -1,10 +1,16 @@ import { Variant, VariantForm } from '../../domain/entities'; import { VariantRepository } from '../../domain/repositories/variant'; +import { mockTags } from './tag'; const mockProduct = { id: 1, name: 'Product 1', - category: { id: 1, name: 'Category 1', station: 'NONE' as const, createdAt: '2024-03-20T00:00:00.000Z' }, + category: { + id: 1, + name: 'Category 1', + station: 'NONE' as const, + createdAt: '2024-03-20T00:00:00.000Z', + }, imageUrl: 'https://example.com/1.jpg', saleType: 'purchase' as const, status: 'published' as const, @@ -13,8 +19,71 @@ const mockProduct = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; +type MenuProductSeed = { + id: number; + name: string; + category: string; + variants: [number, string][]; +}; + +const menuProductSeeds: MenuProductSeed[] = [ + { + id: 11, + name: 'Pancong', + category: 'Snacks', + variants: [ + [101, 'Choco'], + [102, 'Matcha'], + [103, 'Vanilla'], + [104, 'Ice Cream'], + ], + }, + { + id: 12, + name: 'Salted Caramel Macchiato', + category: 'Drinks', + variants: [[105, 'Original']], + }, + { + id: 13, + name: 'Coffee Latte', + category: 'Drinks', + variants: [ + [106, 'Hot'], + [107, 'Iced'], + ], + }, +]; + +export const createMenuVariants = (): Variant[] => + menuProductSeeds.flatMap((seed) => + seed.variants.map(([id, name]) => ({ + id, + name, + price: 20000, + materials: [], + product: { + ...mockProduct, + id: seed.id, + name: seed.name, + category: { + ...mockProduct.category, + id: seed.category === 'Snacks' ? 11 : 12, + name: seed.category, + }, + }, + createdAt: '2024-03-20T00:00:00.000Z', + values: [], + pricingTiers: [], + isAvailable: true, + isSellable: true, + tags: [], + })) + ); + const initialVariants: Variant[] = [ { id: 1, @@ -27,6 +96,7 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, { id: 2, @@ -39,15 +109,24 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, ]; export class MockVariantRepository implements VariantRepository { variants: Variant[] = [...initialVariants]; + lastSubmittedValues: VariantForm | null = null; + private nextId = 3; private shouldFail = false; + private resolveTags(tagIds: number[]) { + return mockTags + .filter((tag) => tagIds.includes(tag.id)) + .map((tag) => ({ tag, taggedAt: new Date().toISOString() })); + } + setShouldFail(value: boolean) { this.shouldFail = value; } @@ -94,12 +173,14 @@ export class MockVariantRepository implements VariantRepository { async createVariant(formValues: VariantForm): Promise { if (this.shouldFail) throw new Error('Failed to create variant'); + this.lastSubmittedValues = formValues; this.variants.push({ id: this.nextId++, name: formValues.name, price: formValues.price, description: formValues.description, recipe: formValues.recipe, + imageUrl: formValues.imageUrl || undefined, materials: [], product: mockProduct, createdAt: new Date().toISOString(), @@ -107,6 +188,7 @@ export class MockVariantRepository implements VariantRepository { pricingTiers: formValues.pricingTiers, isAvailable: true, isSellable: true, + tags: this.resolveTags(formValues.tagIds), }); } @@ -115,6 +197,7 @@ export class MockVariantRepository implements VariantRepository { variantId: number ): Promise { if (this.shouldFail) throw new Error('Failed to update variant'); + this.lastSubmittedValues = formValues; const idx = this.variants.findIndex((v) => v.id === variantId); if (idx === -1) throw new Error('Variant not found'); this.variants[idx] = { @@ -123,11 +206,14 @@ export class MockVariantRepository implements VariantRepository { price: formValues.price, description: formValues.description, recipe: formValues.recipe, + imageUrl: formValues.imageUrl || undefined, + tags: this.resolveTags(formValues.tagIds), }; } reset() { this.variants = [...initialVariants]; + this.lastSubmittedValues = null; this.nextId = 3; this.shouldFail = false; } diff --git a/libs/ui/src/domain/entities/Product.ts b/libs/ui/src/domain/entities/Product.ts index b705c876..15b83315 100644 --- a/libs/ui/src/domain/entities/Product.ts +++ b/libs/ui/src/domain/entities/Product.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { Category } from './Category'; +import { ProductTag } from './Tag'; export type ProductSaleType = 'purchase' | 'rental'; @@ -23,6 +24,7 @@ export type Product = { availableQuantity?: number; isSellable: boolean; sellableQuantity?: number; + tags: ProductTag[]; }; export type Option = { diff --git a/libs/ui/src/domain/entities/Tag.ts b/libs/ui/src/domain/entities/Tag.ts new file mode 100644 index 00000000..a2e886d8 --- /dev/null +++ b/libs/ui/src/domain/entities/Tag.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const tagColors = [ + 'red', + 'orange', + 'yellow', + 'green', + 'blue', + 'purple', + 'pink', + 'gray', +] as const; + +export type TagColor = (typeof tagColors)[number]; + +export type Tag = { + id: number; + name: string; + color: TagColor; + isHighlighted: boolean; + sortOrder: number; + variantCount: number; + createdAt: string; +}; + +export type VariantTag = { + tag: Tag; + taggedAt: string; +}; + +export type ProductTag = { + tag: Tag; + scope: 'product' | 'variant'; + variantIds: number[]; + taggedAt: string; +}; + +export type TagForm = { + name: string; + color: TagColor; + isHighlighted: boolean; + sortOrder: number; +}; + +export const tagFormSchema = z.object({ + name: z.string().min(1).max(100), + color: z.enum(tagColors), + isHighlighted: z.boolean(), + sortOrder: z.number().int(), +}) satisfies z.ZodType; diff --git a/libs/ui/src/domain/entities/Variant.ts b/libs/ui/src/domain/entities/Variant.ts index 92cfb7df..b1d0f3ac 100644 --- a/libs/ui/src/domain/entities/Variant.ts +++ b/libs/ui/src/domain/entities/Variant.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { Material } from './Material'; import { Product } from './Product'; +import { VariantTag } from './Tag'; export type PricingTier = { upToMinutes: number; @@ -13,6 +14,7 @@ export type Variant = { price: number; description?: string; recipe?: string; + imageUrl?: string; materials: { id: number; materialId: number; @@ -27,6 +29,7 @@ export type Variant = { availableQuantity?: number; isSellable: boolean; sellableQuantity?: number; + tags: VariantTag[]; }; export type VariantValue = { @@ -44,6 +47,7 @@ export type VariantForm = { price: number; description?: string; recipe?: string; + imageUrl?: string; materials: { id?: number; materialId: number; @@ -56,6 +60,7 @@ export type VariantForm = { optionValueId: number; }[]; pricingTiers: PricingTier[]; + tagIds: number[]; }; export const variantFormSchema = z.object({ @@ -63,6 +68,7 @@ export const variantFormSchema = z.object({ name: z.string().min(1), description: z.string(), recipe: z.string(), + imageUrl: z.string().optional(), materials: z.array( z.lazy(() => z.object({ materialId: z.number(), amount: z.number() })) ), @@ -70,4 +76,5 @@ export const variantFormSchema = z.object({ pricingTiers: z.array( z.lazy(() => z.object({ upToMinutes: z.number(), price: z.number() })) ), + tagIds: z.array(z.number()), }); diff --git a/libs/ui/src/domain/entities/index.ts b/libs/ui/src/domain/entities/index.ts index 42f336a7..563cfc47 100644 --- a/libs/ui/src/domain/entities/index.ts +++ b/libs/ui/src/domain/entities/index.ts @@ -27,3 +27,4 @@ export * from './PublicTable'; export * from './Cart'; export * from './Payment'; export * from './KdsDevice'; +export * from './Tag'; diff --git a/libs/ui/src/domain/repositories/index.ts b/libs/ui/src/domain/repositories/index.ts index 06e873cc..4c2bee64 100644 --- a/libs/ui/src/domain/repositories/index.ts +++ b/libs/ui/src/domain/repositories/index.ts @@ -39,3 +39,4 @@ export * from './payment'; export * from './customer'; export * from './kdsDevice'; export * from './pushToken'; +export * from './tag'; diff --git a/libs/ui/src/domain/repositories/tag.ts b/libs/ui/src/domain/repositories/tag.ts new file mode 100644 index 00000000..d5132442 --- /dev/null +++ b/libs/ui/src/domain/repositories/tag.ts @@ -0,0 +1,15 @@ +import { Tag, TagForm } from '../entities'; + +export interface TagRepository { + fetchTagList: () => Promise; + + fetchTagById: (tagId: number) => Promise; + + deleteTagById: (tagId: number) => Promise; + + createTag: (formValues: TagForm) => Promise; + + updateTag: (formValues: TagForm, tagId: number) => Promise; + + setTagVariants: (tagId: number, variantIds: number[]) => Promise; +} diff --git a/libs/ui/src/domain/usecases/index.ts b/libs/ui/src/domain/usecases/index.ts index 2c4d5793..4953ae80 100644 --- a/libs/ui/src/domain/usecases/index.ts +++ b/libs/ui/src/domain/usecases/index.ts @@ -95,3 +95,8 @@ export * from './kdsDeviceRegister'; export * from './kdsDeviceUnregister'; export * from './kdsTestNotification'; export * from './IUsecase'; +export * from './tagList'; +export * from './tagDelete'; +export * from './tagUpdate'; +export * from './tagCreate'; +export * from './tagAssignment'; diff --git a/libs/ui/src/domain/usecases/menuItemDetail.test.ts b/libs/ui/src/domain/usecases/menuItemDetail.test.ts index d6534f61..dc6fcbfc 100644 --- a/libs/ui/src/domain/usecases/menuItemDetail.test.ts +++ b/libs/ui/src/domain/usecases/menuItemDetail.test.ts @@ -36,6 +36,7 @@ function addSingleOptionProduct(repository: MockMenuRepository): { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; const variant: Variant = { id: 4, @@ -55,6 +56,7 @@ function addSingleOptionProduct(repository: MockMenuRepository): { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; repository.products = [...repository.products, product]; repository.variants = [...repository.variants, variant]; diff --git a/libs/ui/src/domain/usecases/menuList.test.ts b/libs/ui/src/domain/usecases/menuList.test.ts index 3f496bf6..181389c8 100644 --- a/libs/ui/src/domain/usecases/menuList.test.ts +++ b/libs/ui/src/domain/usecases/menuList.test.ts @@ -4,7 +4,10 @@ import { MenuListState, MenuListParams, } from './menuList'; -import { MockMenuListQueryRepository, MockMenuRepository } from '../../data/mock'; +import { + MockMenuListQueryRepository, + MockMenuRepository, +} from '../../data/mock'; import { UsecaseTester, flushPromises } from '../../utils/usecase'; const createTester = ( @@ -32,7 +35,9 @@ describe('MenuListUsecase', () => { variants: [], query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: null, + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }); @@ -45,7 +50,9 @@ describe('MenuListUsecase', () => { variants: repository.variants, query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: null, + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }); @@ -61,7 +68,9 @@ describe('MenuListUsecase', () => { variants: repository.variants, query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: null, + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }); @@ -74,7 +83,9 @@ describe('MenuListUsecase', () => { variants: repository.variants, query: '', selectedCategoryId: 2, + selectedTagId: null, selectedProductId: null, + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }); @@ -117,7 +128,9 @@ describe('MenuListUsecase', () => { variants: [], query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: null, + selectedVariantId: null, errorMessage: 'Failed to fetch menu', fetchDebounceDelay: 0, }); @@ -161,6 +174,55 @@ describe('MenuListUsecase', () => { expect(menuList.state.categories).toEqual(categories); }); + describe('tag selection', () => { + it('clears the selected category when a tag is selected', async () => { + const menuList = createTester(new MockMenuRepository()); + await flushPromises(); + + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedCategoryId: 2 }); + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedTagId: 1 }); + + expect(menuList.state.selectedTagId).toBe(1); + expect(menuList.state.selectedCategoryId).toBeNull(); + }); + + it('clears the selected tag when a category is selected', async () => { + const menuList = createTester(new MockMenuRepository()); + await flushPromises(); + + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedTagId: 1 }); + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedCategoryId: 2 }); + + expect(menuList.state.selectedCategoryId).toBe(2); + expect(menuList.state.selectedTagId).toBeNull(); + }); + + it('clears both when null is given for both', async () => { + const menuList = createTester(new MockMenuRepository()); + await flushPromises(); + + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedTagId: 1 }); + menuList.dispatch({ + type: 'CHANGE_PARAMS', + selectedCategoryId: null, + selectedTagId: null, + }); + + expect(menuList.state.selectedCategoryId).toBeNull(); + expect(menuList.state.selectedTagId).toBeNull(); + }); + + it('keeps the selected tag when only the query changes', async () => { + const menuList = createTester(new MockMenuRepository()); + await flushPromises(); + + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedTagId: 1 }); + menuList.dispatch({ type: 'CHANGE_PARAMS', query: 'kopi' }); + + expect(menuList.state.selectedTagId).toBe(1); + }); + }); + describe('item selection', () => { it('holds the selected product id, from any fetch state, without changing it', async () => { const repository = new MockMenuRepository(); @@ -173,6 +235,28 @@ describe('MenuListUsecase', () => { expect(menuList.state.selectedProductId).toBe(1); }); + it('keeps the variant id alongside the selected product', async () => { + const repository = new MockMenuRepository(); + const menuList = createTester(repository); + await flushPromises(); + + menuList.dispatch({ type: 'SELECT_ITEM', productId: 1, variantId: 2 }); + + expect(menuList.state.selectedProductId).toBe(1); + expect(menuList.state.selectedVariantId).toBe(2); + }); + + it('selects a product without a variant when none is given, dropping a previous one', async () => { + const repository = new MockMenuRepository(); + const menuList = createTester(repository); + await flushPromises(); + + menuList.dispatch({ type: 'SELECT_ITEM', productId: 1, variantId: 2 }); + menuList.dispatch({ type: 'SELECT_ITEM', productId: 2 }); + + expect(menuList.state.selectedVariantId).toBeNull(); + }); + it('clears the selected product id', async () => { const repository = new MockMenuRepository(); const menuList = createTester(repository); @@ -182,6 +266,7 @@ describe('MenuListUsecase', () => { menuList.dispatch({ type: 'CLEAR_ITEM' }); expect(menuList.state.selectedProductId).toBeNull(); + expect(menuList.state.selectedVariantId).toBeNull(); }); it('reads the initial selection from the query repository when the params do not seed one', () => { diff --git a/libs/ui/src/domain/usecases/menuList.ts b/libs/ui/src/domain/usecases/menuList.ts index 6b143917..79ec177d 100644 --- a/libs/ui/src/domain/usecases/menuList.ts +++ b/libs/ui/src/domain/usecases/menuList.ts @@ -10,7 +10,9 @@ type Context = { variants: Variant[]; query: string; selectedCategoryId: number | null; + selectedTagId: number | null; selectedProductId: number | null; + selectedVariantId: number | null; errorMessage: string | null; fetchDebounceDelay: number; }; @@ -38,6 +40,7 @@ export type MenuListAction = type: 'CHANGE_PARAMS'; query?: string; selectedCategoryId?: number | null; + selectedTagId?: number | null; fetchDebounceDelay?: number; } | { @@ -46,7 +49,7 @@ export type MenuListAction = categories: Category[]; variants: Variant[]; } - | { type: 'SELECT_ITEM'; productId: number } + | { type: 'SELECT_ITEM'; productId: number; variantId?: number } | { type: 'CLEAR_ITEM' }; export type MenuListParams = { @@ -86,9 +89,11 @@ export class MenuListUsecase extends Usecase< variants: this.params.variants ?? [], query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: this.params.selectedProductId ?? this.menuListQueryRepository.getSelectedProductId(), + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }; @@ -139,6 +144,12 @@ export class MenuListUsecase extends Usecase< ([state, { type: _type, fetchDebounceDelay = 0, ...params }]) => ({ ...state, ...params, + ...(typeof params.selectedTagId === 'number' && { + selectedCategoryId: null, + }), + ...(typeof params.selectedCategoryId === 'number' && { + selectedTagId: null, + }), fetchDebounceDelay, type: 'changingParams', }) @@ -160,14 +171,16 @@ export class MenuListUsecase extends Usecase< // it. .with( [P._, { type: 'SELECT_ITEM' }], - ([state, { productId }]) => ({ + ([state, { productId, variantId }]) => ({ ...state, selectedProductId: productId, + selectedVariantId: variantId ?? null, }) ) .with([P._, { type: 'CLEAR_ITEM' }], ([state]) => ({ ...state, selectedProductId: null, + selectedVariantId: null, })) .otherwise(() => state); } diff --git a/libs/ui/src/domain/usecases/tagAssignment.test.ts b/libs/ui/src/domain/usecases/tagAssignment.test.ts new file mode 100644 index 00000000..0500ccf6 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagAssignment.test.ts @@ -0,0 +1,190 @@ +import { + TagAssignmentUsecase, + TagAssignmentState, + TagAssignmentAction, + TagAssignmentParams, +} from './tagAssignment'; +import { + MockTagRepository, + MockVariantRepository, + createMenuVariants, + mockTags, +} from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +const PANCONG_ID = 11; +const SALTED_CARAMEL_ID = 12; +const COFFEE_LATTE_ID = 13; +const PANCONG_ICE_CREAM_ID = 104; +const SALTED_CARAMEL_ORIGINAL_ID = 105; +const COFFEE_LATTE_IDS = [106, 107]; + +const createTester = ( + options: { taggedVariantIds?: number[]; shouldFail?: boolean } = {} +) => { + const tagRepository = new MockTagRepository(); + const variantRepository = new MockVariantRepository(); + variantRepository.variants = createMenuVariants().map((variant) => + (options.taggedVariantIds ?? []).includes(variant.id) + ? { + ...variant, + tags: [{ tag: mockTags[0], taggedAt: '2024-03-22T00:00:00.000Z' }], + } + : variant + ); + if (options.shouldFail) tagRepository.setShouldFail(true); + + const usecase = new TagAssignmentUsecase(tagRepository, variantRepository, { + tagId: mockTags[0].id, + }); + const tester = new UsecaseTester< + TagAssignmentUsecase, + TagAssignmentState, + TagAssignmentAction, + TagAssignmentParams + >(usecase); + return { tester, tagRepository }; +}; + +describe('TagAssignmentUsecase', () => { + describe('loading', () => { + it('should transition loading → loaded with already-tagged variants selected', async () => { + const { tester } = createTester({ + taggedVariantIds: [PANCONG_ICE_CREAM_ID, SALTED_CARAMEL_ORIGINAL_ID], + }); + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('loaded'); + expect(tester.state.tag?.id).toBe(mockTags[0].id); + expect(tester.state.variants).toHaveLength(7); + expect(tester.state.selectedVariantIds).toEqual([ + PANCONG_ICE_CREAM_ID, + SALTED_CARAMEL_ORIGINAL_ID, + ]); + }); + + it('should transition loading → error → loading → loaded on retry', async () => { + const { tester, tagRepository } = createTester({ shouldFail: true }); + + await flushPromises(); + expect(tester.state.type).toBe('error'); + expect(tester.state.errorMessage).toBe('Failed to fetch tag assignments'); + + tagRepository.setShouldFail(false); + tester.dispatch({ type: 'FETCH' }); + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('loaded'); + }); + }); + + describe('toggling', () => { + it('should select every variant when ticking a product', async () => { + const { tester } = createTester(); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: COFFEE_LATTE_ID }); + expect(tester.state.selectedVariantIds).toEqual(COFFEE_LATTE_IDS); + }); + + it('should clear every variant when unticking a fully selected product', async () => { + const { tester } = createTester({ taggedVariantIds: COFFEE_LATTE_IDS }); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: COFFEE_LATTE_ID }); + expect(tester.state.selectedVariantIds).toEqual([]); + }); + + it('should leave a product partially selected when unticking one of its variants', async () => { + const { tester } = createTester({ taggedVariantIds: COFFEE_LATTE_IDS }); + await flushPromises(); + + tester.dispatch({ + type: 'TOGGLE_VARIANT', + variantId: COFFEE_LATTE_IDS[1], + }); + expect(tester.state.selectedVariantIds).toEqual([COFFEE_LATTE_IDS[0]]); + }); + + it('should select the remaining variants when ticking a partially selected product', async () => { + const { tester } = createTester({ + taggedVariantIds: [PANCONG_ICE_CREAM_ID], + }); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: PANCONG_ID }); + expect([...tester.state.selectedVariantIds].sort()).toEqual([ + 101, 102, 103, 104, + ]); + }); + + it('should toggle a single-variant product through its only variant', async () => { + const { tester } = createTester(); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: SALTED_CARAMEL_ID }); + expect(tester.state.selectedVariantIds).toEqual([ + SALTED_CARAMEL_ORIGINAL_ID, + ]); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: SALTED_CARAMEL_ID }); + expect(tester.state.selectedVariantIds).toEqual([]); + }); + }); + + describe('search', () => { + it('should store the search query without touching the selection', async () => { + const { tester } = createTester({ + taggedVariantIds: [PANCONG_ICE_CREAM_ID], + }); + await flushPromises(); + + tester.dispatch({ type: 'SEARCH', query: 'latte' }); + expect(tester.state.query).toBe('latte'); + expect(tester.state.selectedVariantIds).toEqual([PANCONG_ICE_CREAM_ID]); + }); + }); + + describe('saving', () => { + it('should send exactly the ticked variant ids and reach saveSuccess', async () => { + const { tester, tagRepository } = createTester({ + taggedVariantIds: [PANCONG_ICE_CREAM_ID], + }); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: SALTED_CARAMEL_ID }); + tester.dispatch({ + type: 'TOGGLE_VARIANT', + variantId: PANCONG_ICE_CREAM_ID, + }); + tester.dispatch({ type: 'SAVE' }); + expect(tester.state.type).toBe('saving'); + + await flushPromises(); + expect(tester.state.type).toBe('saveSuccess'); + expect(tagRepository.lastSubmittedVariantIds).toEqual([ + SALTED_CARAMEL_ORIGINAL_ID, + ]); + }); + + it('should reach saveError and allow saving again', async () => { + const { tester, tagRepository } = createTester(); + await flushPromises(); + + tagRepository.setShouldFail(true); + tester.dispatch({ type: 'SAVE' }); + await flushPromises(); + expect(tester.state.type).toBe('saveError'); + expect(tester.state.errorMessage).toBe('Save failed'); + + tagRepository.setShouldFail(false); + tester.dispatch({ type: 'SAVE' }); + expect(tester.state.type).toBe('saving'); + + await flushPromises(); + expect(tester.state.type).toBe('saveSuccess'); + }); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagAssignment.ts b/libs/ui/src/domain/usecases/tagAssignment.ts new file mode 100644 index 00000000..c51b28db --- /dev/null +++ b/libs/ui/src/domain/usecases/tagAssignment.ts @@ -0,0 +1,206 @@ +import { match, P } from 'ts-pattern'; +import { Tag, Variant } from '../entities'; +import { TagRepository, VariantRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + tag: Tag | null; + variants: Variant[]; + selectedVariantIds: number[]; + query: string; + errorMessage: string | null; +}; + +export type TagAssignmentState = ( + | { type: 'idle' } + | { type: 'loading' } + | { type: 'loaded' } + | { type: 'error' } + | { type: 'saving' } + | { type: 'saveSuccess' } + | { type: 'saveError' } +) & + Context; + +export type TagAssignmentAction = + | { type: 'FETCH' } + | { type: 'FETCH_SUCCESS'; tag: Tag; variants: Variant[] } + | { type: 'FETCH_ERROR'; errorMessage: string } + | { type: 'SEARCH'; query: string } + | { type: 'TOGGLE_VARIANT'; variantId: number } + | { type: 'TOGGLE_PRODUCT'; productId: number } + | { type: 'SAVE' } + | { type: 'SAVE_SUCCESS' } + | { type: 'SAVE_ERROR'; errorMessage: string }; + +export type TagAssignmentParams = { + tagId: number; +}; + +const toggleVariantIds = (selectedVariantIds: number[], variantId: number) => + selectedVariantIds.includes(variantId) + ? selectedVariantIds.filter((id) => id !== variantId) + : [...selectedVariantIds, variantId]; + +const toggleProductVariantIds = ( + state: TagAssignmentState, + productId: number +) => { + const productVariantIds = state.variants + .filter((variant) => variant.product.id === productId) + .map((variant) => variant.id); + const isEveryVariantSelected = + productVariantIds.length > 0 && + productVariantIds.every((id) => state.selectedVariantIds.includes(id)); + const otherVariantIds = state.selectedVariantIds.filter( + (id) => !productVariantIds.includes(id) + ); + return isEveryVariantSelected + ? otherVariantIds + : [...otherVariantIds, ...productVariantIds]; +}; + +export class TagAssignmentUsecase extends Usecase< + TagAssignmentState, + TagAssignmentAction, + TagAssignmentParams +> { + params: TagAssignmentParams; + tagRepository: TagRepository; + variantRepository: VariantRepository; + + constructor( + tagRepository: TagRepository, + variantRepository: VariantRepository, + params: TagAssignmentParams + ) { + super(); + this.tagRepository = tagRepository; + this.variantRepository = variantRepository; + this.params = params; + } + + getInitialState(): TagAssignmentState { + return { + type: 'idle', + tag: null, + variants: [], + selectedVariantIds: [], + query: '', + errorMessage: null, + }; + } + + getNextState( + state: TagAssignmentState, + action: TagAssignmentAction + ): TagAssignmentState { + return match([state, action]) + .returnType() + .with( + [{ type: P.union('idle', 'error') }, { type: 'FETCH' }], + ([state]) => ({ ...state, type: 'loading', errorMessage: null }) + ) + .with( + [{ type: 'loading' }, { type: 'FETCH_SUCCESS' }], + ([state, { tag, variants }]) => ({ + ...state, + type: 'loaded', + tag, + variants, + selectedVariantIds: variants + .filter((variant) => + (variant.tags ?? []).some( + (variantTag) => variantTag.tag.id === tag.id + ) + ) + .map((variant) => variant.id), + }) + ) + .with( + [{ type: 'loading' }, { type: 'FETCH_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'error', + errorMessage, + }) + ) + .with( + [{ type: P.union('loaded', 'saveError') }, { type: 'SEARCH' }], + ([state, { query }]) => ({ ...state, query }) + ) + .with( + [{ type: P.union('loaded', 'saveError') }, { type: 'TOGGLE_VARIANT' }], + ([state, { variantId }]) => ({ + ...state, + selectedVariantIds: toggleVariantIds( + state.selectedVariantIds, + variantId + ), + }) + ) + .with( + [{ type: P.union('loaded', 'saveError') }, { type: 'TOGGLE_PRODUCT' }], + ([state, { productId }]) => ({ + ...state, + selectedVariantIds: toggleProductVariantIds(state, productId), + }) + ) + .with( + [{ type: P.union('loaded', 'saveError') }, { type: 'SAVE' }], + ([state]) => ({ ...state, type: 'saving', errorMessage: null }) + ) + .with([{ type: 'saving' }, { type: 'SAVE_SUCCESS' }], ([state]) => ({ + ...state, + type: 'saveSuccess', + })) + .with( + [{ type: 'saving' }, { type: 'SAVE_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'saveError', + errorMessage, + }) + ) + .otherwise(() => state); + } + + onStateChange( + state: TagAssignmentState, + dispatch: (action: TagAssignmentAction) => void + ): void { + match(state) + .with({ type: 'idle' }, () => dispatch({ type: 'FETCH' })) + .with({ type: 'loading' }, () => { + Promise.all([ + this.tagRepository.fetchTagById(this.params.tagId), + this.variantRepository.fetchVariantList({ + page: 1, + itemPerPage: 1000, + query: '', + sortBy: 'created_at', + orderBy: 'asc', + optionValueIds: [], + }), + ]) + .then(([tag, { variants }]) => + dispatch({ type: 'FETCH_SUCCESS', tag, variants }) + ) + .catch(() => + dispatch({ + type: 'FETCH_ERROR', + errorMessage: 'Failed to fetch tag assignments', + }) + ); + }) + .with({ type: 'saving' }, ({ selectedVariantIds }) => { + this.tagRepository + .setTagVariants(this.params.tagId, selectedVariantIds) + .then(() => dispatch({ type: 'SAVE_SUCCESS' })) + .catch(() => + dispatch({ type: 'SAVE_ERROR', errorMessage: 'Save failed' }) + ); + }) + .otherwise(() => undefined); + } +} diff --git a/libs/ui/src/domain/usecases/tagCreate.test.ts b/libs/ui/src/domain/usecases/tagCreate.test.ts new file mode 100644 index 00000000..52d6d0d7 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagCreate.test.ts @@ -0,0 +1,64 @@ +import { TagCreateUsecase, TagCreateState, TagCreateAction } from './tagCreate'; +import { MockTagRepository } from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +describe('TagCreateUsecase', () => { + describe('success flow', () => { + it('should transition loaded → submitting → submitSuccess', async () => { + const repository = new MockTagRepository(); + const usecase = new TagCreateUsecase(repository); + const tester = new UsecaseTester< + TagCreateUsecase, + TagCreateState, + TagCreateAction, + undefined + >(usecase); + + expect(tester.state.type).toBe('loaded'); + + tester.dispatch({ + type: 'SUBMIT', + values: { + name: 'New Tag', + color: 'green', + isHighlighted: false, + sortOrder: 0, + }, + }); + expect(tester.state.type).toBe('submitting'); + + await flushPromises(); + expect(tester.state.type).toBe('submitSuccess'); + }); + }); + + describe('error flow', () => { + it('should transition loaded → submitting → submitError', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagCreateUsecase(repository); + const tester = new UsecaseTester< + TagCreateUsecase, + TagCreateState, + TagCreateAction, + undefined + >(usecase); + + expect(tester.state.type).toBe('loaded'); + + tester.dispatch({ + type: 'SUBMIT', + values: { + name: 'New Tag', + color: 'green', + isHighlighted: false, + sortOrder: 0, + }, + }); + expect(tester.state.type).toBe('submitting'); + + await flushPromises(); + expect(tester.state.type).toBe('submitError'); + }); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagCreate.ts b/libs/ui/src/domain/usecases/tagCreate.ts new file mode 100644 index 00000000..ec8effb6 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagCreate.ts @@ -0,0 +1,106 @@ +import { match } from 'ts-pattern'; +import { TagForm } from '../entities'; +import { TagRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + errorMessage: string | null; + values: TagForm; +}; + +export type TagCreateState = ( + | { type: 'loaded' } + | { type: 'submitting' } + | { type: 'submitSuccess' } + | { type: 'submitError' } +) & + Context; + +export type TagCreateAction = + | { type: 'SUBMIT'; values: TagForm } + | { type: 'SUBMIT_SUCCESS' } + | { type: 'SUBMIT_ERROR'; errorMessage: string } + | { type: 'SUBMIT_CANCEL' }; + +export class TagCreateUsecase extends Usecase { + params: undefined; + repository: TagRepository; + + constructor(repository: TagRepository) { + super(); + this.repository = repository; + } + + getInitialState(): TagCreateState { + return { + type: 'loaded', + errorMessage: null, + values: { + name: '', + color: 'gray', + isHighlighted: false, + sortOrder: 0, + }, + }; + } + + getNextState(state: TagCreateState, action: TagCreateAction): TagCreateState { + return match([state, action]) + .returnType() + .with( + [{ type: 'loaded' }, { type: 'SUBMIT' }], + ([state, { values }]) => ({ + ...state, + values, + type: 'submitting', + }) + ) + .with( + [{ type: 'submitError' }, { type: 'SUBMIT' }], + ([state, { values }]) => ({ + ...state, + values, + type: 'submitting', + }) + ) + .with( + [{ type: 'submitting' }, { type: 'SUBMIT_SUCCESS' }], + ([state]) => ({ + ...state, + type: 'submitSuccess', + }) + ) + .with( + [{ type: 'submitting' }, { type: 'SUBMIT_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'submitError', + errorMessage, + }) + ) + .with( + [{ type: 'submitError' }, { type: 'SUBMIT_CANCEL' }], + ([state]) => ({ + ...state, + type: 'loaded', + }) + ) + .otherwise(() => state); + } + + onStateChange( + state: TagCreateState, + dispatch: (action: TagCreateAction) => void + ): void { + match(state) + .with({ type: 'submitting' }, ({ values }) => { + this.repository + .createTag(values) + .then(() => dispatch({ type: 'SUBMIT_SUCCESS' })) + .catch(() => + dispatch({ type: 'SUBMIT_ERROR', errorMessage: 'Submit failed' }) + ); + }) + .otherwise(() => undefined); + } +} diff --git a/libs/ui/src/domain/usecases/tagDelete.test.ts b/libs/ui/src/domain/usecases/tagDelete.test.ts new file mode 100644 index 00000000..935c5e53 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagDelete.test.ts @@ -0,0 +1,68 @@ +import { TagDeleteUsecase, TagDeleteState, TagDeleteAction } from './tagDelete'; +import { MockTagRepository } from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +describe('TagDeleteUsecase', () => { + describe('success flow', () => { + it('should transition hidden → shown → deleting → hidden', async () => { + const repository = new MockTagRepository(); + const usecase = new TagDeleteUsecase(repository); + const tester = new UsecaseTester< + TagDeleteUsecase, + TagDeleteState, + TagDeleteAction, + undefined + >(usecase); + + expect(tester.state).toEqual({ type: 'hidden', tagId: null }); + + tester.dispatch({ type: 'SHOW_CONFIRMATION', tagId: 1 }); + expect(tester.state).toEqual({ type: 'shown', tagId: 1 }); + + tester.dispatch({ type: 'DELETE' }); + expect(tester.state.type).toBe('deleting'); + + await flushPromises(); + expect(tester.state.type).toBe('hidden'); + }); + }); + + it('transitions to hidden when HIDE_CONFIRMATION is dispatched from shown', () => { + const repository = new MockTagRepository(); + const usecase = new TagDeleteUsecase(repository); + const tester = new UsecaseTester< + TagDeleteUsecase, + TagDeleteState, + TagDeleteAction, + undefined + >(usecase); + tester.dispatch({ type: 'SHOW_CONFIRMATION', tagId: 1 }); + tester.dispatch({ type: 'HIDE_CONFIRMATION' }); + expect(tester.state.type).toBe('hidden'); + }); + + describe('error flow', () => { + it('should transition hidden → shown → deleting → shown (auto-recovery)', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagDeleteUsecase(repository); + const tester = new UsecaseTester< + TagDeleteUsecase, + TagDeleteState, + TagDeleteAction, + undefined + >(usecase); + + expect(tester.state.type).toBe('hidden'); + + tester.dispatch({ type: 'SHOW_CONFIRMATION', tagId: 1 }); + expect(tester.state.type).toBe('shown'); + + tester.dispatch({ type: 'DELETE' }); + expect(tester.state.type).toBe('deleting'); + + await flushPromises(); + expect(tester.state.type).toBe('shown'); + }); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagDelete.ts b/libs/ui/src/domain/usecases/tagDelete.ts new file mode 100644 index 00000000..159a25d4 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagDelete.ts @@ -0,0 +1,101 @@ +import { match } from 'ts-pattern'; +import { TagRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + tagId: number | null; +}; + +export type TagDeleteState = ( + | { type: 'hidden' } + | { type: 'shown' } + | { type: 'deleting' } + | { type: 'deletingSuccess' } + | { type: 'deletingError' } +) & + Context; + +export type TagDeleteAction = + | { type: 'SHOW_CONFIRMATION'; tagId: number } + | { type: 'HIDE_CONFIRMATION' } + | { type: 'DELETE' } + | { type: 'DELETE_SUCCESS' } + | { type: 'DELETE_ERROR' } + | { type: 'DELETE_CANCEL' }; + +export class TagDeleteUsecase extends Usecase { + params: undefined; + repository: TagRepository; + + constructor(repository: TagRepository) { + super(); + this.repository = repository; + } + + getInitialState(): TagDeleteState { + return { + type: 'hidden', + tagId: null, + }; + } + getNextState(state: TagDeleteState, action: TagDeleteAction): TagDeleteState { + return match([state, action]) + .returnType() + .with( + [{ type: 'hidden' }, { type: 'SHOW_CONFIRMATION' }], + ([_state, { tagId }]) => ({ type: 'shown', tagId }) + ) + .with([{ type: 'shown' }, { type: 'HIDE_CONFIRMATION' }], ([state]) => ({ + ...state, + type: 'hidden', + tagId: null, + })) + .with([{ type: 'shown' }, { type: 'DELETE' }], ([state]) => ({ + ...state, + type: 'deleting', + })) + .with([{ type: 'deleting' }, { type: 'DELETE_ERROR' }], ([state]) => ({ + ...state, + type: 'deletingError', + })) + .with( + [{ type: 'deletingError' }, { type: 'DELETE_CANCEL' }], + ([state]) => ({ + ...state, + type: 'shown', + }) + ) + .with([{ type: 'deleting' }, { type: 'DELETE_SUCCESS' }], ([state]) => ({ + ...state, + type: 'deletingSuccess', + })) + .with( + [{ type: 'deletingSuccess' }, { type: 'HIDE_CONFIRMATION' }], + ([state]) => ({ + ...state, + type: 'hidden', + tagId: null, + }) + ) + .otherwise(() => state); + } + onStateChange( + state: TagDeleteState, + dispatch: (action: TagDeleteAction) => void + ): void { + match(state) + .with({ type: 'deleting' }, ({ tagId }) => { + this.repository + .deleteTagById(tagId ?? NaN) + .then(() => dispatch({ type: 'DELETE_SUCCESS' })) + .catch(() => dispatch({ type: 'DELETE_ERROR' })); + }) + .with({ type: 'deletingSuccess' }, () => { + dispatch({ type: 'HIDE_CONFIRMATION' }); + }) + .with({ type: 'deletingError' }, () => { + dispatch({ type: 'DELETE_CANCEL' }); + }) + .otherwise(() => undefined); + } +} diff --git a/libs/ui/src/domain/usecases/tagList.test.ts b/libs/ui/src/domain/usecases/tagList.test.ts new file mode 100644 index 00000000..0788ffe4 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagList.test.ts @@ -0,0 +1,123 @@ +import { + TagListUsecase, + TagListAction, + TagListState, + TagListParams, +} from './tagList'; +import { MockTagRepository } from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +describe('TagListUsecase', () => { + describe('success flow', () => { + it('should transition loading → loaded → revalidating → loaded', async () => { + const repository = new MockTagRepository(); + const usecase = new TagListUsecase(repository, { tags: [] }); + const tagList = new UsecaseTester< + TagListUsecase, + TagListState, + TagListAction, + TagListParams + >(usecase); + + expect(tagList.state).toEqual({ + type: 'loading', + tags: [], + errorMessage: null, + }); + + await flushPromises(); + expect(tagList.state).toEqual({ + type: 'loaded', + tags: repository.tags, + errorMessage: null, + }); + + tagList.dispatch({ type: 'FETCH' }); + expect(tagList.state).toEqual({ + type: 'revalidating', + tags: repository.tags, + errorMessage: null, + }); + + await flushPromises(); + expect(tagList.state).toEqual({ + type: 'loaded', + tags: repository.tags, + errorMessage: null, + }); + }); + }); + + describe('failed flow', () => { + it('should transition loading → error → loading → loaded', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagListUsecase(repository, { tags: [] }); + const tagList = new UsecaseTester< + TagListUsecase, + TagListState, + TagListAction, + TagListParams + >(usecase); + + expect(tagList.state).toEqual({ + type: 'loading', + tags: [], + errorMessage: null, + }); + + await flushPromises(); + expect(tagList.state).toEqual({ + type: 'error', + tags: [], + errorMessage: 'Failed to fetch tags', + }); + + repository.setShouldFail(false); + tagList.dispatch({ type: 'FETCH' }); + expect(tagList.state).toEqual({ + type: 'loading', + tags: [], + errorMessage: null, + }); + + await flushPromises(); + expect(tagList.state).toEqual({ + type: 'loaded', + tags: repository.tags, + errorMessage: null, + }); + }); + }); + + it('show loaded state when initial data is given', async () => { + const repository = new MockTagRepository(); + + const tags = [ + { + id: 1, + name: 'Tag Test 1', + color: 'red' as const, + isHighlighted: false, + sortOrder: 0, + variantCount: 0, + createdAt: new Date().toISOString(), + }, + ]; + + const usecase = new TagListUsecase(repository, { tags }); + + const tagList = new UsecaseTester< + TagListUsecase, + TagListState, + TagListAction, + TagListParams + >(usecase); + + expect(tagList.state).toEqual({ + type: 'loaded', + tags, + errorMessage: null, + }); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagList.ts b/libs/ui/src/domain/usecases/tagList.ts new file mode 100644 index 00000000..72c401b1 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagList.ts @@ -0,0 +1,120 @@ +import { match, P } from 'ts-pattern'; +import { Tag } from '../entities'; +import { TagRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + tags: Tag[]; + errorMessage: string | null; +}; + +export type TagListState = ( + | { type: 'idle' } + | { type: 'loading' } + | { type: 'loaded' } + | { type: 'error' } + | { type: 'revalidating' } +) & + Context; + +export type TagListAction = + | { type: 'FETCH' } + | { type: 'FETCH_SUCCESS'; tags: Tag[] } + | { type: 'FETCH_ERROR'; message: string } + | { type: 'REVALIDATE'; tags: Tag[] } + | { type: 'REVALIDATE_FINISH'; tags: Tag[] }; + +export type TagListParams = { + tags: Tag[]; +}; + +export class TagListUsecase extends Usecase< + TagListState, + TagListAction, + TagListParams +> { + params: TagListParams; + repository: TagRepository; + + constructor(repository: TagRepository, params: TagListParams) { + super(); + this.repository = repository; + this.params = params; + } + + getInitialState() { + const state: TagListState = { + type: this.params.tags.length >= 1 ? 'loaded' : 'idle', + errorMessage: null, + tags: this.params.tags, + }; + return state; + } + + getNextState(state: TagListState, action: TagListAction) { + return match([state, action]) + .returnType() + .with( + [{ type: P.union('idle', 'error') }, { type: 'FETCH' }], + ([state]) => ({ ...state, type: 'loading', errorMessage: null }) + ) + .with( + [{ type: 'loading' }, { type: 'FETCH_SUCCESS' }], + ([state, { tags }]) => ({ + ...state, + type: 'loaded', + tags, + errorMessage: null, + }) + ) + .with( + [{ type: 'loading' }, { type: 'FETCH_ERROR' }], + ([state, { message }]) => ({ + ...state, + type: 'error', + errorMessage: message, + }) + ) + .with([{ type: 'loaded' }, { type: 'FETCH' }], ([state]) => ({ + ...state, + type: 'revalidating', + errorMessage: null, + })) + .with( + [{ type: 'revalidating' }, { type: 'REVALIDATE_FINISH' }], + ([state, { tags }]) => ({ + ...state, + type: 'loaded', + tags, + errorMessage: null, + }) + ) + .otherwise(() => state); + } + + onStateChange( + state: TagListState, + dispatch: (action: TagListAction) => void + ) { + match(state) + .with({ type: 'idle' }, () => dispatch({ type: 'FETCH' })) + .with({ type: 'loading' }, () => + this.repository + .fetchTagList() + .then((tags) => dispatch({ type: 'FETCH_SUCCESS', tags })) + .catch(() => + dispatch({ + type: 'FETCH_ERROR', + message: 'Failed to fetch tags', + }) + ) + ) + .with({ type: 'revalidating' }, ({ tags }) => { + this.repository + .fetchTagList() + .then((tags) => dispatch({ type: 'REVALIDATE_FINISH', tags })) + .catch(() => dispatch({ type: 'REVALIDATE_FINISH', tags })); + }) + .otherwise(() => undefined); + } +} diff --git a/libs/ui/src/domain/usecases/tagUpdate.test.ts b/libs/ui/src/domain/usecases/tagUpdate.test.ts new file mode 100644 index 00000000..bde6d825 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagUpdate.test.ts @@ -0,0 +1,117 @@ +import { + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams, +} from './tagUpdate'; +import { MockTagRepository } from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +describe('TagUpdateUsecase', () => { + describe('success flow - fetch then submit', () => { + it('should transition loading → loaded → submitting → submitSuccess', async () => { + const repository = new MockTagRepository(); + const usecase = new TagUpdateUsecase(repository, { tagId: 1, tag: null }); + const tester = new UsecaseTester< + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams + >(usecase); + + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('loaded'); + + tester.dispatch({ + type: 'SUBMIT', + values: { + name: 'Updated Tag', + color: 'blue', + isHighlighted: true, + sortOrder: 5, + }, + }); + expect(tester.state.type).toBe('submitting'); + + await flushPromises(); + expect(tester.state.type).toBe('submitSuccess'); + }); + }); + + describe('error flow - fetch error', () => { + it('should transition loading → error → loading → loaded', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagUpdateUsecase(repository, { tagId: 1, tag: null }); + const tester = new UsecaseTester< + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams + >(usecase); + + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('error'); + + repository.setShouldFail(false); + tester.dispatch({ type: 'FETCH' }); + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('loaded'); + }); + }); + + describe('error flow - submit error', () => { + it('should transition loaded → submitting → submitError', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagUpdateUsecase(repository, { + tagId: 1, + tag: repository.tags[0], + }); + const tester = new UsecaseTester< + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams + >(usecase); + + expect(tester.state.type).toBe('loaded'); + + tester.dispatch({ + type: 'SUBMIT', + values: { + name: 'Updated Tag', + color: 'blue', + isHighlighted: true, + sortOrder: 5, + }, + }); + expect(tester.state.type).toBe('submitting'); + + await flushPromises(); + expect(tester.state.type).toBe('submitError'); + }); + }); + + it('starts in loaded state when data is preloaded', () => { + const repository = new MockTagRepository(); + const existing = repository.tags[0]; + const usecase = new TagUpdateUsecase(repository, { + tagId: 1, + tag: existing, + }); + const tester = new UsecaseTester< + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams + >(usecase); + expect(tester.state.type).toBe('loaded'); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagUpdate.ts b/libs/ui/src/domain/usecases/tagUpdate.ts new file mode 100644 index 00000000..0fe58d8d --- /dev/null +++ b/libs/ui/src/domain/usecases/tagUpdate.ts @@ -0,0 +1,170 @@ +import { match } from 'ts-pattern'; +import { Tag, TagForm } from '../entities'; +import { TagRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + errorMessage: string | null; + values: TagForm; +}; + +export type TagUpdateState = ( + | { type: 'idle' } + | { type: 'loading' } + | { type: 'loaded' } + | { type: 'error' } + | { type: 'submitting' } + | { type: 'submitSuccess' } + | { type: 'submitError' } +) & + Context; + +export type TagUpdateAction = + | { type: 'FETCH' } + | { type: 'FETCH_SUCCESS'; values: TagForm } + | { type: 'FETCH_ERROR'; errorMessage: string } + | { type: 'SUBMIT'; values: TagForm } + | { type: 'SUBMIT_SUCCESS' } + | { type: 'SUBMIT_ERROR'; errorMessage: string } + | { type: 'SUBMIT_CANCEL' }; + +export type TagUpdateParams = { + tagId: number; + tag: Tag | null; +}; + +export class TagUpdateUsecase extends Usecase< + TagUpdateState, + TagUpdateAction, + TagUpdateParams +> { + params: TagUpdateParams; + repository: TagRepository; + + constructor(repository: TagRepository, params: TagUpdateParams) { + super(); + this.repository = repository; + this.params = params; + } + + getInitialState(): TagUpdateState { + return { + type: this.params.tag !== null ? 'loaded' : 'idle', + errorMessage: null, + values: { + name: this.params.tag?.name ?? '', + color: this.params.tag?.color ?? 'gray', + isHighlighted: this.params.tag?.isHighlighted ?? false, + sortOrder: this.params.tag?.sortOrder ?? 0, + }, + }; + } + + getNextState(state: TagUpdateState, action: TagUpdateAction): TagUpdateState { + return match([state, action]) + .returnType() + .with([{ type: 'idle' }, { type: 'FETCH' }], ([state]) => ({ + ...state, + type: 'loading', + })) + .with( + [{ type: 'loading' }, { type: 'FETCH_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'error', + errorMessage, + }) + ) + .with([{ type: 'error' }, { type: 'FETCH' }], ([state]) => ({ + ...state, + type: 'loading', + })) + .with( + [{ type: 'loading' }, { type: 'FETCH_SUCCESS' }], + ([state, { values }]) => ({ + ...state, + type: 'loaded', + values, + }) + ) + .with( + [{ type: 'loaded' }, { type: 'SUBMIT' }], + ([state, { values }]) => ({ + ...state, + values, + type: 'submitting', + }) + ) + .with( + [{ type: 'submitError' }, { type: 'SUBMIT' }], + ([state, { values }]) => ({ + ...state, + values, + type: 'submitting', + }) + ) + .with( + [{ type: 'submitting' }, { type: 'SUBMIT_SUCCESS' }], + ([state]) => ({ + ...state, + type: 'submitSuccess', + }) + ) + .with( + [{ type: 'submitting' }, { type: 'SUBMIT_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'submitError', + errorMessage, + }) + ) + .with( + [{ type: 'submitError' }, { type: 'SUBMIT_CANCEL' }], + ([state]) => ({ + ...state, + type: 'loaded', + }) + ) + .otherwise(() => state); + } + + onStateChange( + state: TagUpdateState, + dispatch: (action: TagUpdateAction) => void + ): void { + match(state) + .with({ type: 'idle' }, () => { + dispatch({ type: 'FETCH' }); + }) + .with({ type: 'loading' }, () => { + this.repository + .fetchTagById(this.params.tagId) + .then((tag) => + dispatch({ + type: 'FETCH_SUCCESS', + values: { + name: tag.name, + color: tag.color, + isHighlighted: tag.isHighlighted, + sortOrder: tag.sortOrder, + }, + }) + ) + .catch(() => + dispatch({ + type: 'FETCH_ERROR', + errorMessage: 'Failed to fetch tag', + }) + ); + }) + .with({ type: 'submitting' }, ({ values }) => { + this.repository + .updateTag(values, this.params.tagId) + .then(() => dispatch({ type: 'SUBMIT_SUCCESS' })) + .catch(() => + dispatch({ type: 'SUBMIT_ERROR', errorMessage: 'Submit failed' }) + ); + }) + .otherwise(() => undefined); + } +} diff --git a/libs/ui/src/domain/usecases/variantCreate.test.ts b/libs/ui/src/domain/usecases/variantCreate.test.ts index da789994..76fc54d9 100644 --- a/libs/ui/src/domain/usecases/variantCreate.test.ts +++ b/libs/ui/src/domain/usecases/variantCreate.test.ts @@ -4,7 +4,7 @@ import { VariantCreateAction, VariantCreateParams, } from './variantCreate'; -import { MockVariantRepository, MockProductRepository } from '../../data/mock'; +import { MockVariantRepository, MockProductRepository, mockTags } from '../../data/mock'; import { UsecaseTester, flushPromises } from '../../utils/usecase'; describe('VariantCreateUsecase', () => { @@ -22,7 +22,7 @@ describe('VariantCreateUsecase', () => { tester.dispatch({ type: 'SUBMIT', - values: { productId: 1, name: 'New Variant', price: 50000, description: '', materials: [], values: [], pricingTiers: [] }, + values: { productId: 1, name: 'New Variant', price: 50000, description: '', materials: [], values: [], pricingTiers: [], tagIds: [] }, }); expect(tester.state.type).toBe('submitting'); @@ -68,7 +68,7 @@ describe('VariantCreateUsecase', () => { tester.dispatch({ type: 'SUBMIT', - values: { productId: 1, name: 'New Variant', price: 50000, description: '', materials: [], values: [], pricingTiers: [] }, + values: { productId: 1, name: 'New Variant', price: 50000, description: '', materials: [], values: [], pricingTiers: [], tagIds: [] }, }); expect(tester.state.type).toBe('submitting'); @@ -108,6 +108,7 @@ describe('VariantCreateUsecase', () => { materials: [], values: [], pricingTiers: [], + tagIds: [], }, }); @@ -115,4 +116,104 @@ describe('VariantCreateUsecase', () => { expect(tester.state.type).toBe('submitSuccess'); expect(variantRepository.variants.at(-1)?.recipe).toBe('Shake well before serving'); }); + + it('starts with an empty imageUrl and persists one when submitting', async () => { + const variantRepository = new MockVariantRepository(); + const productRepository = new MockProductRepository(); + const usecase = new VariantCreateUsecase(variantRepository, productRepository, { + productId: 1, + product: productRepository.products[0], + }); + const tester = new UsecaseTester(usecase); + expect(tester.state.values.imageUrl).toBe(''); + + tester.dispatch({ + type: 'SUBMIT', + values: { + productId: 1, + name: 'Ice Cream', + price: 50000, + description: '', + recipe: '', + imageUrl: 'https://example.com/ice-cream.jpg', + materials: [], + values: [], + pricingTiers: [], + tagIds: [], + }, + }); + + await flushPromises(); + expect(tester.state.type).toBe('submitSuccess'); + expect(variantRepository.variants.at(-1)?.imageUrl).toBe('https://example.com/ice-cream.jpg'); + }); + + describe('tag pre-fill', () => { + const buildProductWithTags = (productRepository: MockProductRepository) => { + const [newTag, bestSellerTag] = mockTags; + const timestamp = '2024-03-20T00:00:00.000Z'; + return { + ...productRepository.products[0], + tags: [ + { tag: newTag, scope: 'variant' as const, variantIds: [1], taggedAt: timestamp }, + { tag: bestSellerTag, scope: 'product' as const, variantIds: [1, 2], taggedAt: timestamp }, + ], + }; + }; + + it('pre-fills tagIds with the product-scope tags of a preloaded product', () => { + const productRepository = new MockProductRepository(); + const usecase = new VariantCreateUsecase(new MockVariantRepository(), productRepository, { + productId: 1, + product: buildProductWithTags(productRepository), + }); + const tester = new UsecaseTester(usecase); + + expect(tester.state.values.tagIds).toEqual([2]); + }); + + it('starts with no tagIds when the product has no tags', () => { + const productRepository = new MockProductRepository(); + const usecase = new VariantCreateUsecase(new MockVariantRepository(), productRepository, { + productId: 1, + product: productRepository.products[0], + }); + const tester = new UsecaseTester(usecase); + + expect(tester.state.values.tagIds).toEqual([]); + }); + + it('pre-fills tagIds once the product is fetched', async () => { + const productRepository = new MockProductRepository(); + productRepository.products[0] = buildProductWithTags(productRepository); + const usecase = new VariantCreateUsecase(new MockVariantRepository(), productRepository, { + productId: 1, + product: null, + }); + const tester = new UsecaseTester(usecase); + + await flushPromises(); + + expect(tester.state.type).toBe('loaded'); + expect(tester.state.values.tagIds).toEqual([2]); + }); + + it('persists the submitted tagIds', async () => { + const variantRepository = new MockVariantRepository(); + const productRepository = new MockProductRepository(); + const usecase = new VariantCreateUsecase(variantRepository, productRepository, { + productId: 1, + product: productRepository.products[0], + }); + const tester = new UsecaseTester(usecase); + + tester.dispatch({ + type: 'SUBMIT', + values: { ...tester.state.values, name: 'Oat Milk', price: 50000, tagIds: [1] }, + }); + + await flushPromises(); + expect(variantRepository.variants.at(-1)?.tags?.map(({ tag }) => tag.id)).toEqual([1]); + }); + }); }); diff --git a/libs/ui/src/domain/usecases/variantCreate.ts b/libs/ui/src/domain/usecases/variantCreate.ts index 77ea1647..de1f4e4b 100644 --- a/libs/ui/src/domain/usecases/variantCreate.ts +++ b/libs/ui/src/domain/usecases/variantCreate.ts @@ -29,6 +29,11 @@ export type VariantCreateAction = | { type: 'SUBMIT_ERROR'; errorMessage: string } | { type: 'SUBMIT_CANCEL' }; +const getProductScopeTagIds = (product: Product | null) => + (product?.tags ?? []) + .filter(({ scope }) => scope === 'product') + .map(({ tag }) => tag.id); + export type VariantCreateParams = { productId: number; product: Product | null; @@ -62,8 +67,10 @@ export class VariantCreateUsecase extends Usecase< price: 0, description: '', recipe: '', + imageUrl: '', values: [], pricingTiers: [], + tagIds: getProductScopeTagIds(this.params.product), }; return this.params.product !== null ? { @@ -108,6 +115,7 @@ export class VariantCreateUsecase extends Usecase< ...state, type: 'loaded', product, + values: { ...state.values, tagIds: getProductScopeTagIds(product) }, }) ) .with( diff --git a/libs/ui/src/domain/usecases/variantUpdate.test.ts b/libs/ui/src/domain/usecases/variantUpdate.test.ts index 013e3bec..0e2a03b2 100644 --- a/libs/ui/src/domain/usecases/variantUpdate.test.ts +++ b/libs/ui/src/domain/usecases/variantUpdate.test.ts @@ -4,7 +4,7 @@ import { VariantUpdateAction, VariantUpdateParams, } from './variantUpdate'; -import { MockVariantRepository, MockProductRepository } from '../../data/mock'; +import { MockVariantRepository, MockProductRepository, mockTags } from '../../data/mock'; import { UsecaseTester, flushPromises } from '../../utils/usecase'; describe('VariantUpdateUsecase', () => { @@ -27,7 +27,7 @@ describe('VariantUpdateUsecase', () => { tester.dispatch({ type: 'SUBMIT', - values: { productId: 1, name: 'Updated Variant', price: 60000, description: '', materials: [], values: [], pricingTiers: [] }, + values: { productId: 1, name: 'Updated Variant', price: 60000, description: '', materials: [], values: [], pricingTiers: [], tagIds: [] }, }); expect(tester.state.type).toBe('submitting'); @@ -80,7 +80,7 @@ describe('VariantUpdateUsecase', () => { tester.dispatch({ type: 'SUBMIT', - values: { productId: 1, name: 'Updated Variant', price: 60000, description: '', materials: [], values: [], pricingTiers: [] }, + values: { productId: 1, name: 'Updated Variant', price: 60000, description: '', materials: [], values: [], pricingTiers: [], tagIds: [] }, }); expect(tester.state.type).toBe('submitting'); @@ -140,6 +140,7 @@ describe('VariantUpdateUsecase', () => { materials: [], values: [], pricingTiers: [], + tagIds: [], }, }); @@ -147,4 +148,118 @@ describe('VariantUpdateUsecase', () => { expect(tester.state.type).toBe('submitSuccess'); expect(variantRepository.variants.find((v) => v.id === existing.id)?.recipe).toBe('Shake well before serving'); }); + + it('pre-fills imageUrl from the fetched variant, empty when absent', () => { + const variantRepository = new MockVariantRepository(); + const productRepository = new MockProductRepository(); + const buildTester = (variant: (typeof variantRepository.variants)[number]) => + new UsecaseTester( + new VariantUpdateUsecase(variantRepository, productRepository, { + variantId: 1, + variant, + productId: 1, + product: productRepository.products[0], + }) + ); + + const withImage = buildTester({ ...variantRepository.variants[0], imageUrl: 'https://example.com/ice-cream.jpg' }); + const withoutImage = buildTester({ ...variantRepository.variants[0], imageUrl: undefined }); + + expect(withImage.state.values.imageUrl).toBe('https://example.com/ice-cream.jpg'); + expect(withoutImage.state.values.imageUrl).toBe(''); + }); + + it.each([ + ['persists an updated imageUrl', undefined, 'https://example.com/ice-cream.jpg', 'https://example.com/ice-cream.jpg'], + ['clears the imageUrl when emptied', 'https://example.com/ice-cream.jpg', '', undefined], + ])('%s', async (_name, initialImageUrl, submittedImageUrl, expectedImageUrl) => { + const variantRepository = new MockVariantRepository(); + const productRepository = new MockProductRepository(); + const existing = { ...variantRepository.variants[0], imageUrl: initialImageUrl }; + variantRepository.variants[0] = existing; + const usecase = new VariantUpdateUsecase(variantRepository, productRepository, { + variantId: existing.id, + variant: existing, + productId: 1, + product: productRepository.products[0], + }); + const tester = new UsecaseTester(usecase); + + tester.dispatch({ + type: 'SUBMIT', + values: { + productId: 1, + name: existing.name, + price: existing.price, + description: '', + recipe: '', + imageUrl: submittedImageUrl, + materials: [], + values: [], + pricingTiers: [], + tagIds: [], + }, + }); + + await flushPromises(); + expect(tester.state.type).toBe('submitSuccess'); + expect(variantRepository.variants.find((v) => v.id === existing.id)?.imageUrl).toBe(expectedImageUrl); + }); + + describe('tag ids', () => { + const taggedAt = '2024-03-20T00:00:00.000Z'; + + it('pre-fills tagIds from the preloaded variant tags', () => { + const variantRepository = new MockVariantRepository(); + const productRepository = new MockProductRepository(); + const usecase = new VariantUpdateUsecase(variantRepository, productRepository, { + variantId: 1, + variant: { ...variantRepository.variants[0], tags: mockTags.map((tag) => ({ tag, taggedAt })) }, + productId: 1, + product: productRepository.products[0], + }); + const tester = new UsecaseTester(usecase); + + expect(tester.state.values.tagIds).toEqual([1, 2]); + }); + + it('pre-fills tagIds from the fetched variant', async () => { + const variantRepository = new MockVariantRepository(); + variantRepository.variants[0] = { + ...variantRepository.variants[0], + tags: [{ tag: mockTags[1], taggedAt }], + }; + const productRepository = new MockProductRepository(); + const usecase = new VariantUpdateUsecase(variantRepository, productRepository, { + variantId: 1, + variant: null, + productId: 1, + product: null, + }); + const tester = new UsecaseTester(usecase); + + await flushPromises(); + + expect(tester.state.type).toBe('loaded'); + expect(tester.state.values.tagIds).toEqual([2]); + }); + + it('persists the submitted tagIds, including an empty list', async () => { + const variantRepository = new MockVariantRepository(); + const productRepository = new MockProductRepository(); + const usecase = new VariantUpdateUsecase(variantRepository, productRepository, { + variantId: 1, + variant: { ...variantRepository.variants[0], tags: [{ tag: mockTags[0], taggedAt }] }, + productId: 1, + product: productRepository.products[0], + }); + const tester = new UsecaseTester(usecase); + + tester.dispatch({ type: 'SUBMIT', values: { ...tester.state.values, tagIds: [] } }); + + await flushPromises(); + expect(tester.state.type).toBe('submitSuccess'); + expect(variantRepository.lastSubmittedValues?.tagIds).toEqual([]); + }); + }); }); diff --git a/libs/ui/src/domain/usecases/variantUpdate.ts b/libs/ui/src/domain/usecases/variantUpdate.ts index 7e730ec2..57d1a767 100644 --- a/libs/ui/src/domain/usecases/variantUpdate.ts +++ b/libs/ui/src/domain/usecases/variantUpdate.ts @@ -71,12 +71,14 @@ export class VariantUpdateUsecase extends Usecase< price: this.params.variant?.price ?? 0, description: this.params.variant?.description ?? '', recipe: this.params.variant?.recipe ?? '', + imageUrl: this.params.variant?.imageUrl ?? '', values: this.params.variant?.values.map((value) => ({ id: value.id, optionValueId: value.optionValueId, })) ?? [], pricingTiers: this.params.variant?.pricingTiers ?? [], + tagIds: this.params.variant?.tags?.map(({ tag }) => tag.id) ?? [], }, }; } @@ -178,11 +180,13 @@ export class VariantUpdateUsecase extends Usecase< productId: variant.product.id, description: variant.description, recipe: variant.recipe, + imageUrl: variant.imageUrl ?? '', values: variant.values.map((value) => ({ id: value.id, optionValueId: value.optionValueId, })), pricingTiers: variant.pricingTiers, + tagIds: variant.tags?.map(({ tag }) => tag.id) ?? [], }, }) ) diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx index 77493081..288c1b96 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx @@ -12,6 +12,7 @@ import { MockSessionRepository, } from '../../../data/mock'; import { + Tag, CartUsecase, MenuItemDetailUsecase, MenuListParams, @@ -41,6 +42,83 @@ const pendingQrisPayment: PendingPayment = { canCancel: true, }; +const tagNew: Tag = { + id: 1, + name: 'New', + color: 'green', + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const tagBestSeller: Tag = { + id: 2, + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 2, + variantCount: 3, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const tagRegular: Tag = { + id: 3, + name: 'Regular Pick', + color: 'gray', + isHighlighted: false, + sortOrder: 3, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const createTaggedMenuRepository = () => { + const menuRepository = new MockMenuRepository(); + menuRepository.products = menuRepository.products.map((product) => ({ + ...product, + })); + menuRepository.variants = menuRepository.variants.map((variant) => ({ + ...variant, + product: + menuRepository.products.find(({ id }) => id === variant.product.id) ?? + variant.product, + })); + const [esKopiSusu, nasiGoreng] = menuRepository.products; + const [regular, large, friedRice] = menuRepository.variants; + + esKopiSusu.tags = [ + { + tag: tagNew, + scope: 'variant', + variantIds: [large.id], + taggedAt: '2024-06-01T00:00:00.000Z', + }, + { + tag: tagBestSeller, + scope: 'product', + variantIds: [regular.id, large.id], + taggedAt: '2024-06-01T00:00:00.000Z', + }, + { + tag: tagRegular, + scope: 'product', + variantIds: [regular.id, large.id], + taggedAt: '2024-06-01T00:00:00.000Z', + }, + ]; + large.tags = [{ tag: tagNew, taggedAt: '2024-06-01T00:00:00.000Z' }]; + nasiGoreng.tags = [ + { + tag: tagBestSeller, + scope: 'product', + variantIds: [friedRice.id], + taggedAt: '2024-07-01T00:00:00.000Z', + }, + ]; + + return menuRepository; +}; + const renderHandler = ({ menuRepository = new MockMenuRepository(), tableRepository = new MockPublicTableRepository(), @@ -193,6 +271,38 @@ describe('MenuListHandler', () => { expect(screen.getByText('Es Kopi Susu')).toBeTruthy(); }); + it('shows tag badges on a tagged product card, naming the variant for variant-scope tags', async () => { + const menuRepository = new MockMenuRepository(); + const tag = { + id: 1, + name: 'New', + color: 'green' as const, + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', + }; + menuRepository.products = [ + { + ...menuRepository.products[0], + tags: [ + { + tag, + scope: 'variant', + variantIds: [2], + taggedAt: '2024-03-20T00:00:00.000Z', + }, + ], + }, + menuRepository.products[1], + ]; + renderHandler({ menuRepository }); + + await settle(); + + expect(screen.getByText('New · Es Kopi Susu - Large')).toBeTruthy(); + }); + it('filters to a single category when its chip is pressed', async () => { const user = userEvent.setup(); renderHandler(); @@ -335,6 +445,308 @@ describe('MenuListHandler', () => { expect(screen.queryByText('0')).toBeNull(); }); + describe('highlight sections', () => { + const sectionTitle = (name: string) => screen.getAllByText(name)[1]; + const largeVariantCard = () => + screen.queryByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }); + + it('renders one section per highlighted tag above the category groups, in tag sortOrder', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + const newTitle = sectionTitle('New'); + const bestSellerTitle = sectionTitle('Best Seller'); + const firstCategoryTitle = screen.getAllByText('Minuman')[1]; + + expect( + newTitle.compareDocumentPosition(bestSellerTitle) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + bestSellerTitle.compareDocumentPosition(firstCategoryTitle) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('does not render a section for a tag that is not highlighted', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + expect(screen.queryByText('Regular Pick')).toBeNull(); + }); + + it('renders no sections when nothing is tagged', async () => { + renderHandler(); + await settle(); + + expect(screen.queryByText('New')).toBeNull(); + expect(screen.queryByText('Best Seller')).toBeNull(); + }); + + it('lists the most recently tagged entry first within a section', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + const nasiGorengCard = screen.getAllByRole('button', { + name: 'Nasi Goreng', + })[0]; + const esKopiSusuCard = screen.getAllByRole('button', { + name: 'Es Kopi Susu', + })[0]; + + expect( + nasiGorengCard.compareDocumentPosition(esKopiSusuCard) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('shows a variant entry with the product and variant name and its own price', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + expect( + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ).toBeTruthy(); + expect(screen.getAllByText('Rp 22.000').length).toBeGreaterThan(0); + }); + + it('hides the sections while a category chip is selected', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'Makanan' })); + await settle(); + + expect(largeVariantCard()).toBeNull(); + }); + + it('hides the sections while the search box has text', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.type( + screen.getByPlaceholderText('Cari menu atau varian'), + 'kopi' + ); + + expect(largeVariantCard()).toBeNull(); + }); + + it('brings the sections back once the category filter is cleared', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'Makanan' })); + await settle(); + await user.click(screen.getByRole('button', { name: 'Semua' })); + await settle(); + + expect(largeVariantCard()).toBeTruthy(); + }); + + it('omits entries that are sold out, and the whole section when none remain', async () => { + const menuRepository = createTaggedMenuRepository(); + menuRepository.variants[1].isSellable = false; + renderHandler({ menuRepository }); + await settle(); + + expect(screen.queryByText('New')).toBeNull(); + }); + + it('opens the item sheet for a product entry with nothing preselected', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click( + screen.getAllByRole('button', { name: 'Es Kopi Susu' })[0] + ); + await settle(); + + expect( + screen.getByRole('button', { name: 'Tambah ke Keranjang' }) + ).toBeTruthy(); + }); + + it('opens the item sheet with the tagged variant preselected, skipping straight to its price', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click( + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ); + await settle(); + + expect( + screen.getByRole('button', { + name: 'Tambah ke Keranjang · Rp 22.000', + }) + ).toBeTruthy(); + }); + }); + + describe('highlighted-tag chips', () => { + const chipNames = () => + screen + .getAllByRole('button') + .map((button) => button.textContent) + .filter((text) => + [ + 'Semua', + 'New', + 'Best Seller', + 'Regular Pick', + 'Minuman', + 'Makanan', + ].includes(text ?? '') + ); + + it('renders a chip per non-empty highlighted tag between Semua and the categories', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + expect(chipNames().slice(0, 5)).toEqual([ + 'Semua', + 'New', + 'Best Seller', + 'Minuman', + 'Makanan', + ]); + }); + + it('renders no tag chip for a tag that is not highlighted', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + expect(screen.queryByRole('button', { name: 'Regular Pick' })).toBeNull(); + }); + + it('renders no tag chip for a tag whose entries are all sold out', async () => { + const menuRepository = createTaggedMenuRepository(); + menuRepository.variants[1].isSellable = false; + renderHandler({ menuRepository }); + await settle(); + + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Best Seller' })).toBeTruthy(); + }); + + it('renders no tag chip when nothing is tagged', async () => { + renderHandler(); + await settle(); + + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + }); + + it('shows only that tag’s entries as a list, newest first, replacing sections and category groups', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'Best Seller' })); + await settle(); + + const nasiGorengCard = screen.getByRole('button', { + name: 'Nasi Goreng', + }); + const esKopiSusuCard = screen.getByRole('button', { + name: 'Es Kopi Susu', + }); + expect( + nasiGorengCard.compareDocumentPosition(esKopiSusuCard) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + screen.queryByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ).toBeNull(); + expect(screen.getAllByText('Minuman')).toHaveLength(1); + }); + + it('opens the item sheet with the variant preselected from a tag list entry', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'New' })); + await settle(); + await user.click( + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ); + await settle(); + + expect( + screen.getByRole('button', { + name: 'Tambah ke Keranjang · Rp 22.000', + }) + ).toBeTruthy(); + }); + + it('replaces the tag list with a category when a category chip is pressed', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'New' })); + await settle(); + await user.click(screen.getByRole('button', { name: 'Makanan' })); + await settle(); + + expect(screen.getAllByText('Makanan')).toHaveLength(2); + expect(screen.getByText('Nasi Goreng')).toBeTruthy(); + expect(screen.queryByText('Es Kopi Susu')).toBeNull(); + }); + + it('replaces the category with the tag list when a tag chip is pressed', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'Makanan' })); + await settle(); + await user.click(screen.getByRole('button', { name: 'New' })); + await settle(); + + expect(screen.queryByText('Nasi Goreng')).toBeNull(); + expect( + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ).toBeTruthy(); + }); + + it('brings back the sections and category groups when Semua is pressed', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'New' })); + await settle(); + await user.click(screen.getByRole('button', { name: 'Semua' })); + await settle(); + + expect( + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ).toBeTruthy(); + expect(screen.getAllByText('Makanan')).toHaveLength(2); + }); + }); + describe('the item sheet', () => { it('opens with no network request when a product card is pressed, and does not remount the menu', async () => { const user = userEvent.setup(); @@ -571,9 +983,7 @@ describe('MenuListHandler', () => { await user.click(screen.getByText('Es Kopi Susu')); await settle(); - expect( - screen.getByText(/pembayaran yang belum selesai/) - ).toBeTruthy(); + expect(screen.getByText(/pembayaran yang belum selesai/)).toBeTruthy(); expect( screen.queryByRole('button', { name: /Tambah ke Keranjang/ }) ).toBeNull(); @@ -717,9 +1127,7 @@ describe('MenuListHandler', () => { expect(cancelSpy).not.toHaveBeenCalled(); expect(screen.getByLabelText('Tutup')).toBeTruthy(); - expect( - screen.getByText(/pembayaran yang belum selesai/) - ).toBeTruthy(); + expect(screen.getByText(/pembayaran yang belum selesai/)).toBeTruthy(); }); }); }); diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx index 55bd9b10..dd7e2b93 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx @@ -13,8 +13,10 @@ import { MenuListUsecase } from '../../../domain/usecases/menuList'; import { PaymentCancelUsecase } from '../../../domain/usecases/paymentCancel'; import { TableResolveUsecase } from '../../../domain/usecases/tableResolve'; import { + buildTagHighlights, matchMenuSearch, resolveOptionValueAvailability, + TagHighlightEntry, } from '../../../utils'; import { CartBar } from '../../views/components/cart/CartBar'; import { PendingPaymentBar } from '../../views/components/cart/PendingPaymentBar'; @@ -51,8 +53,10 @@ function groupByCategory(products: Product[], categories: Category[]) { function computePreselectedOptionValueIds( query: string, - product: Product + product: Product, + variant: Variant | undefined ): number[] { + if (variant) return variant.values.map((value) => value.optionValueId); return matchMenuSearch(query, product).matchedOptionValues.map( (value) => value.id ); @@ -70,6 +74,12 @@ function computeStartingPriceByProductId( }, {}); } +function computeVariantNameById(variants: Variant[]): Record { + return Object.fromEntries( + variants.map((variant) => [variant.id, variant.name]) + ); +} + function computeMatchedLabels( query: string, product: Product, @@ -123,6 +133,7 @@ function toItemDetailScreenVariant( price: state.variant?.price ?? null, variantErrorMessage: state.type === 'error' ? state.errorMessage : null, isVariantSellable: state.variant?.isSellable ?? null, + variantTags: state.variant?.tags ?? [], remainingQuantity: state.variant?.isSellable ? state.variant.sellableQuantity : undefined, @@ -205,9 +216,8 @@ export const MenuListHandler = ({ method: pendingPayment.method, } ); - const handledCancelResultRef = useRef< - typeof paymentCancel.state.result - >(null); + const handledCancelResultRef = + useRef(null); const isAddAfterCancelPendingRef = useRef(false); useEffect(() => { @@ -242,17 +252,20 @@ export const MenuListHandler = ({ }, [cart.state.type, cart.dispatch, menuItemDetail.state, menuList.dispatch]); useEffect(() => { - const { selectedProductId, query } = menuList.state; + const { selectedProductId, selectedVariantId, query } = menuList.state; if (selectedProductId !== null) { const product = menuList.state.products.find( (candidate) => candidate.id === selectedProductId ); + const variant = menuList.state.variants.find( + (candidate) => candidate.id === selectedVariantId + ); menuItemDetail.dispatch({ type: 'SELECT_PRODUCT', productId: selectedProductId, product, preselectedOptionValueIds: product - ? computePreselectedOptionValueIds(query, product) + ? computePreselectedOptionValueIds(query, product, variant) : [], }); } @@ -262,7 +275,11 @@ export const MenuListHandler = ({ // (and reset the draft's amount/note) whenever a background // revalidation resolves while the sheet is open. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [menuList.state.selectedProductId, menuItemDetail.dispatch]); + }, [ + menuList.state.selectedProductId, + menuList.state.selectedVariantId, + menuItemDetail.dispatch, + ]); const groups = groupByCategory( menuList.state.products, @@ -276,6 +293,23 @@ export const MenuListHandler = ({ (group) => group.category.id === menuList.state.selectedCategoryId ); + const allHighlights = buildTagHighlights( + menuList.state.products, + menuList.state.variants + ); + + const selectedTagHighlight = + allHighlights.find( + (highlight) => highlight.tag.id === menuList.state.selectedTagId + ) ?? null; + + const shouldShowHighlightSections = + menuList.state.query === '' && + menuList.state.selectedCategoryId === null && + menuList.state.selectedTagId === null; + + const highlightSections = shouldShowHighlightSections ? allHighlights : []; + const startingPriceByProductId = computeStartingPriceByProductId( menuList.state.variants ); @@ -299,9 +333,7 @@ export const MenuListHandler = ({ ) : currentCart && currentCart.itemCount > 0 ? ( - router.push(`/t/${tableResolveUsecase.params.code}/cart`) - } + onPress={() => router.push(`/t/${tableResolveUsecase.params.code}/cart`)} /> ) : null; @@ -426,6 +458,16 @@ export const MenuListHandler = ({ menuList.dispatch({ type: 'CHANGE_PARAMS', selectedCategoryId: categoryId, + selectedTagId: null, + fetchDebounceDelay: 0, + }) + } + chipTags={allHighlights.map((highlight) => highlight.tag)} + selectedTagId={menuList.state.selectedTagId} + onSelectTag={(tagId: number) => + menuList.dispatch({ + type: 'CHANGE_PARAMS', + selectedTagId: tagId, fetchDebounceDelay: 0, }) } @@ -433,8 +475,17 @@ export const MenuListHandler = ({ onItemPress={(product: Product) => menuList.dispatch({ type: 'SELECT_ITEM', productId: product.id }) } + highlightSections={highlightSections} + onHighlightEntryPress={(entry: TagHighlightEntry) => + menuList.dispatch({ + type: 'SELECT_ITEM', + productId: entry.product.id, + variantId: entry.kind === 'variant' ? entry.variant.id : undefined, + }) + } startingPriceByProductId={startingPriceByProductId} matchedLabelsByProductId={matchedLabelsByProductId} + variantNameById={computeVariantNameById(menuList.state.variants)} onHistoryPress={() => router.push('/orders')} preparingCount={preparingCount} cartErrorMessage={ @@ -449,10 +500,19 @@ export const MenuListHandler = ({ })) .with( { type: P.union('changingParams', 'loaded', 'revalidating') }, - () => ({ - type: visibleGroups.length > 0 ? 'loaded' : 'empty', - groups: visibleGroups, - }) + () => + menuList.state.selectedTagId !== null + ? selectedTagHighlight + ? { + type: 'tagLoaded', + tag: selectedTagHighlight.tag, + entries: selectedTagHighlight.entries, + } + : { type: 'empty' } + : { + type: visibleGroups.length > 0 ? 'loaded' : 'empty', + groups: visibleGroups, + } ) .with({ type: 'error' }, () => ({ type: 'error' })) .exhaustive()} diff --git a/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.test.tsx new file mode 100644 index 00000000..daca2e7f --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.test.tsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TagAssignmentHandler } from './TagAssignmentHandler'; +import { + MockAuthRepository, + MockTagRepository, + MockVariantRepository, + createMenuVariants, + mockTags, +} from '../../../data/mock'; +import { AuthLogoutUsecase, TagAssignmentUsecase } from '../../../domain'; +import { flushPromises } from '../../../utils/testUtils'; + +const mockRouterPush = jest.fn(); +jest.mock('solito/router', () => ({ + useRouter: () => ({ + push: mockRouterPush, + replace: jest.fn(), + back: jest.fn(), + }), +})); + +const mockToastShow = jest.fn(); +jest.mock('@tamagui/toast', () => ({ + useToastController: () => ({ show: mockToastShow }), +})); + +const createProps = ( + options: { taggedVariantIds?: number[]; shouldFail?: boolean } = {} +) => { + const tagRepo = new MockTagRepository(); + const variantRepo = new MockVariantRepository(); + variantRepo.variants = createMenuVariants().map((variant) => + (options.taggedVariantIds ?? []).includes(variant.id) + ? { + ...variant, + tags: [{ tag: mockTags[0], taggedAt: '2024-03-22T00:00:00.000Z' }], + } + : variant + ); + if (options.shouldFail) tagRepo.setShouldFail(true); + + return { + tagRepo, + props: { + authLogoutUsecase: new AuthLogoutUsecase(new MockAuthRepository()), + tagAssignmentUsecase: new TagAssignmentUsecase(tagRepo, variantRepo, { + tagId: mockTags[0].id, + }), + }, + }; +}; + +const renderLoaded = async ( + options: Parameters[0] = {} +) => { + const { tagRepo, props } = createProps(options); + render(); + await act(async () => { + await flushPromises(); + }); + return { tagRepo }; +}; + +const checkbox = (name: string) => screen.getByRole('checkbox', { name }); + +describe('TagAssignmentHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('loading and data states', () => { + it('should show skeleton list during initial loading', async () => { + const { props } = createProps(); + render(); + expect(screen.getByTestId('skeleton-list')).toBeTruthy(); + await act(async () => { + await flushPromises(); + }); + }); + + it('should group products by category', async () => { + await renderLoaded(); + + expect(screen.getByRole('heading', { name: 'Snacks' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Drinks' })).toBeTruthy(); + expect(checkbox('Pancong')).toBeTruthy(); + expect(checkbox('Salted Caramel Macchiato')).toBeTruthy(); + expect(checkbox('Coffee Latte')).toBeTruthy(); + }); + + it('should show an error view and refetch on retry', async () => { + const user = userEvent.setup(); + const { tagRepo } = await renderLoaded({ shouldFail: true }); + + expect( + screen.getByRole('heading', { name: 'Failed to Fetch Products' }) + ).toBeTruthy(); + + tagRepo.setShouldFail(false); + await user.click(screen.getByRole('button', { name: 'Retry' })); + await act(async () => { + await flushPromises(); + }); + + expect(checkbox('Pancong')).toBeTruthy(); + }); + }); + + describe('checkbox states', () => { + it('should render unchecked, checked and indeterminate products', async () => { + await renderLoaded({ taggedVariantIds: [104, 105, 106, 107] }); + + expect(checkbox('Pancong').getAttribute('aria-checked')).toBe('mixed'); + expect( + checkbox('Salted Caramel Macchiato').getAttribute('aria-checked') + ).toBe('true'); + expect(checkbox('Coffee Latte').getAttribute('aria-checked')).toBe( + 'true' + ); + }); + + it('should hide the variant layer of a single-variant product', async () => { + await renderLoaded(); + + expect( + screen.queryByRole('button', { + name: 'Show variants of Salted Caramel Macchiato', + }) + ).toBeNull(); + expect( + screen.queryByRole('checkbox', { + name: 'Salted Caramel Macchiato Original', + }) + ).toBeNull(); + }); + + it('should tick every variant when a product is ticked', async () => { + const user = userEvent.setup(); + await renderLoaded(); + + await user.click(checkbox('Coffee Latte')); + await user.click( + screen.getByRole('button', { name: 'Show variants of Coffee Latte' }) + ); + + expect(checkbox('Coffee Latte').getAttribute('aria-checked')).toBe( + 'true' + ); + expect(checkbox('Coffee Latte Hot').getAttribute('aria-checked')).toBe( + 'true' + ); + expect(checkbox('Coffee Latte Iced').getAttribute('aria-checked')).toBe( + 'true' + ); + }); + + it('should make a product indeterminate when one of its variants is unticked', async () => { + const user = userEvent.setup(); + await renderLoaded({ taggedVariantIds: [106, 107] }); + + await user.click( + screen.getByRole('button', { name: 'Show variants of Coffee Latte' }) + ); + await user.click(checkbox('Coffee Latte Iced')); + + expect(checkbox('Coffee Latte').getAttribute('aria-checked')).toBe( + 'mixed' + ); + expect(checkbox('Coffee Latte Hot').getAttribute('aria-checked')).toBe( + 'true' + ); + expect(checkbox('Coffee Latte Iced').getAttribute('aria-checked')).toBe( + 'false' + ); + }); + }); + + describe('search', () => { + it('should filter products by name', async () => { + const user = userEvent.setup(); + await renderLoaded(); + + await user.type( + screen.getByPlaceholderText('Search Products by Name'), + 'latte' + ); + + expect(checkbox('Coffee Latte')).toBeTruthy(); + expect(screen.queryByRole('checkbox', { name: 'Pancong' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Snacks' })).toBeNull(); + }); + + it('should show the empty view when nothing matches', async () => { + const user = userEvent.setup(); + await renderLoaded(); + + await user.type( + screen.getByPlaceholderText('Search Products by Name'), + 'zzz' + ); + + expect( + screen.getByRole('heading', { name: 'Oops, Product is Empty' }) + ).toBeTruthy(); + }); + }); + + describe('saving', () => { + it('should send exactly the ticked variant ids, toast and navigate back to the tag list', async () => { + const user = userEvent.setup(); + const { tagRepo } = await renderLoaded({ taggedVariantIds: [104] }); + + await user.click(checkbox('Salted Caramel Macchiato')); + await user.click(screen.getByRole('button', { name: 'Save' })); + await act(async () => { + await flushPromises(); + }); + + expect(tagRepo.lastSubmittedVariantIds).toEqual([104, 105]); + expect(mockToastShow).toHaveBeenCalledWith('Assign Tag Success'); + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should show an error toast and stay on the page when saving fails', async () => { + const user = userEvent.setup(); + const { tagRepo } = await renderLoaded(); + + tagRepo.setShouldFail(true); + await user.click(screen.getByRole('button', { name: 'Save' })); + await act(async () => { + await flushPromises(); + }); + + expect(mockToastShow).toHaveBeenCalledWith('Assign Tag Error'); + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.tsx b/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.tsx new file mode 100644 index 00000000..50c05aee --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.tsx @@ -0,0 +1,136 @@ +import { useRouter } from 'solito/router'; +import { match, P } from 'ts-pattern'; +import { useEffect } from 'react'; +import { useToastController } from '@tamagui/toast'; +import { + AuthLogoutUsecase, + TagAssignmentState, + TagAssignmentUsecase, + Variant, +} from '../../../domain'; +import { useUsecase, useAuthLogout } from '../hooks'; +import { + TagAssignmentScreen, + TagAssignmentScreenProps, +} from '../../views/screens/pos/TagAssignmentScreen'; +import { TagAssignmentCategoryItem } from '../../views/components'; + +export type TagAssignmentHandlerProps = { + authLogoutUsecase: AuthLogoutUsecase; + tagAssignmentUsecase: TagAssignmentUsecase; +}; + +const matchesQuery = (variant: Variant, query: string) => + [variant.product.name, variant.product.category.name, variant.name].some( + (text) => text.toLowerCase().includes(query) + ); + +const toCategoryItems = ({ + variants, + selectedVariantIds, + query, +}: TagAssignmentState): TagAssignmentCategoryItem[] => { + const normalizedQuery = query.trim().toLowerCase(); + const categories = new Map(); + + variants + .filter((variant) => matchesQuery(variant, normalizedQuery)) + .forEach(({ product }) => { + const category = categories.get(product.category.id) ?? { + id: product.category.id, + name: product.category.name, + products: [], + }; + if (!category.products.some(({ id }) => id === product.id)) { + const productVariants = variants.filter( + (variant) => variant.product.id === product.id + ); + const checkedCount = productVariants.filter((variant) => + selectedVariantIds.includes(variant.id) + ).length; + category.products.push({ + id: product.id, + name: product.name, + checkState: + checkedCount === 0 + ? false + : checkedCount === productVariants.length + ? true + : 'indeterminate', + variants: productVariants.map((variant) => ({ + id: variant.id, + name: variant.name, + isChecked: selectedVariantIds.includes(variant.id), + })), + }); + } + categories.set(category.id, category); + }); + + return [...categories.values()]; +}; + +export const TagAssignmentHandler = ({ + authLogoutUsecase, + tagAssignmentUsecase, +}: TagAssignmentHandlerProps) => { + const authLogout = useAuthLogout(authLogoutUsecase); + const tagAssignment = useUsecase(tagAssignmentUsecase); + const router = useRouter(); + const toast = useToastController(); + + useEffect(() => { + if (tagAssignment.state.type === 'saveSuccess') { + toast.show('Assign Tag Success'); + router.push('/tags'); + } else if (tagAssignment.state.type === 'saveError') { + toast.show('Assign Tag Error'); + } + }, [tagAssignment.state.type, toast, router]); + + return ( + authLogout.dispatch({ type: 'LOGOUT' })} + tagName={tagAssignment.state.tag?.name} + searchValue={tagAssignment.state.query} + onSearchValueChange={(query) => + tagAssignment.dispatch({ type: 'SEARCH', query }) + } + onRetryButtonPress={() => tagAssignment.dispatch({ type: 'FETCH' })} + onProductToggle={(productId) => + tagAssignment.dispatch({ type: 'TOGGLE_PRODUCT', productId }) + } + onVariantToggle={(variantId) => + tagAssignment.dispatch({ type: 'TOGGLE_VARIANT', variantId }) + } + selectedVariantCount={tagAssignment.state.selectedVariantIds.length} + onSavePress={() => tagAssignment.dispatch({ type: 'SAVE' })} + isSaveDisabled={ + tagAssignment.state.type !== 'loaded' && + tagAssignment.state.type !== 'saveError' + } + isSaving={tagAssignment.state.type === 'saving'} + serverError={ + tagAssignment.state.type === 'saveError' + ? 'Failed to save. Please try again.' + : undefined + } + variant={match(tagAssignment.state) + .returnType() + .with({ type: P.union('idle', 'loading') }, () => ({ + type: 'loading', + })) + .with({ type: 'error' }, () => ({ type: 'error' })) + .with( + { type: P.union('loaded', 'saving', 'saveSuccess', 'saveError') }, + (state) => { + const categories = toCategoryItems(state); + return categories.length > 0 + ? { type: 'loaded', categories } + : { type: 'empty' }; + } + ) + .exhaustive()} + /> + ); +}; diff --git a/libs/ui/src/presentation/handlers/pos/TagCreateHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/TagCreateHandler.test.tsx new file mode 100644 index 00000000..2eed4a65 --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagCreateHandler.test.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TagCreateHandler } from './TagCreateHandler'; +import { MockAuthRepository, MockTagRepository } from '../../../data/mock'; +import { AuthLogoutUsecase, TagCreateUsecase } from '../../../domain'; +import { flushPromises } from '../../../utils/testUtils'; + +const mockRouterPush = jest.fn(); +jest.mock('solito/router', () => ({ + useRouter: () => ({ push: mockRouterPush, replace: jest.fn(), back: jest.fn() }), +})); + +const mockToastShow = jest.fn(); +jest.mock('@tamagui/toast', () => ({ + useToastController: () => ({ show: mockToastShow }), +})); + +const createProps = (options: { shouldFail?: boolean } = {}) => { + const tagRepo = new MockTagRepository(); + if (options.shouldFail) tagRepo.setShouldFail(true); + return { + authLogoutUsecase: new AuthLogoutUsecase(new MockAuthRepository()), + tagCreateUsecase: new TagCreateUsecase(tagRepo), + }; +}; + +describe('TagCreateHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('form rendering', () => { + it('should render the create form in loaded state', () => { + render(); + expect(screen.getByRole('button', { name: 'Submit' })).toBeTruthy(); + }); + + it('should render the name input field', () => { + render(); + expect(screen.getByRole('textbox', { name: 'Name' })).toBeTruthy(); + }); + + it('should render a swatch for every colour with gray selected', () => { + render(); + expect(screen.getAllByRole('radio').length).toBe(8); + expect( + screen.getByRole('radio', { name: 'Gray' }).getAttribute('aria-checked') + ).toBe('true'); + }); + + it('should render the highlight switch off by default', () => { + render(); + expect( + screen.getByRole('switch').getAttribute('aria-checked') + ).toBe('false'); + }); + }); + + describe('navigation', () => { + it('should navigate to "/tags" after successful creation', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should create tag with the chosen colour and highlight', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + + render( + + ); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('radio', { name: 'Green' })); + await user.click(screen.getByRole('switch')); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(tagRepo.tags.at(-1)).toMatchObject({ + name: 'New Tag', + color: 'green', + isHighlighted: true, + }); + }); + + it('should not navigate when creation fails', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it('should not navigate when name field is empty (validation fails)', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it('should not navigate without any user interaction', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + }); + + describe('validation', () => { + it('should show error message when name field is empty and submit is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('String must contain at least 1 character(s)')).toBeTruthy(); + }); + }); + + describe('loading states', () => { + it('should disable submit button and show spinner while submitting', async () => { + const user = userEvent.setup(); + let resolveCreate!: () => void; + const tagRepo = new MockTagRepository(); + jest.spyOn(tagRepo, 'createTag').mockImplementation( + () => new Promise((resolve) => { resolveCreate = resolve; }) + ); + + render( + + ); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await act(async () => { + await flushPromises(); + }); + + expect((screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('spinner')).toBeTruthy(); + + await act(async () => { + resolveCreate(); + await flushPromises(); + }); + + expect(screen.queryByTestId('spinner')).toBeNull(); + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should not disable submit button before any interaction', () => { + render(); + expect((screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement).disabled).toBe(false); + }); + }); + + describe('toast notifications', () => { + it('should show toast error message when creation fails', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockToastShow).toHaveBeenCalledWith('Create Tag Error'); + }); + }); + + describe('error banner', () => { + it('should show error banner when creation fails', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('Failed to submit. Please try again.')).toBeTruthy(); + }); + + it('should not show error banner before any submission', () => { + render(); + expect(screen.queryByText('Failed to submit. Please try again.')).toBeNull(); + }); + }); +}); diff --git a/libs/ui/src/presentation/handlers/pos/TagCreateHandler.tsx b/libs/ui/src/presentation/handlers/pos/TagCreateHandler.tsx new file mode 100644 index 00000000..608d4952 --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagCreateHandler.tsx @@ -0,0 +1,64 @@ +import { useRouter } from 'solito/router'; +import { AuthLogoutUsecase, TagCreateUsecase } from '../../../domain'; +import { match, P } from 'ts-pattern'; +import { useEffect } from 'react'; +import { useToastController } from '@tamagui/toast'; +import { useUsecase, useAuthLogout } from '../hooks'; +import { + TagCreateScreen, + TagCreateScreenProps, +} from '../../views/screens/pos/TagCreateScreen'; + +export type TagCreateHandlerProps = { + authLogoutUsecase: AuthLogoutUsecase; + tagCreateUsecase: TagCreateUsecase; +}; + +export const TagCreateHandler = ({ + authLogoutUsecase, + tagCreateUsecase, +}: TagCreateHandlerProps) => { + const authLogout = useAuthLogout(authLogoutUsecase); + const tagCreate = useUsecase(tagCreateUsecase); + const router = useRouter(); + const toast = useToastController(); + + useEffect(() => { + if (tagCreate.state.type === 'submitSuccess') { + toast.show('Create Tag Success'); + router.push('/tags'); + } else if (tagCreate.state.type === 'submitError') { + toast.show('Create Tag Error'); + } + }, [tagCreate.state.type, toast, router]); + + return ( + authLogout.dispatch({ type: 'LOGOUT' })} + defaultValues={tagCreate.state.values} + isSubmitDisabled={ + tagCreate.state.type === 'submitting' || + tagCreate.state.type === 'submitSuccess' + } + isSubmitting={tagCreate.state.type === 'submitting'} + serverError={ + tagCreate.state.type === 'submitError' + ? 'Failed to submit. Please try again.' + : undefined + } + onSubmit={(values) => tagCreate.dispatch({ type: 'SUBMIT', values })} + variant={match(tagCreate.state) + .returnType() + .with({ type: 'loaded' }, () => ({ type: 'loaded' })) + .with( + { + type: P.union('submitting', 'submitSuccess', 'submitError'), + }, + () => ({ + type: 'loaded', + }) + ) + .exhaustive()} + /> + ); +}; diff --git a/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx new file mode 100644 index 00000000..724c190f --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx @@ -0,0 +1,345 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TagListHandler } from './TagListHandler'; +import { MockAuthRepository, MockTagRepository } from '../../../data/mock'; +import { + AuthLogoutUsecase, + TagDeleteUsecase, + TagListUsecase, +} from '../../../domain'; +import { flushPromises } from '../../../utils/testUtils'; + +const mockRouterPush = jest.fn(); +jest.mock('solito/router', () => ({ + useRouter: () => ({ push: mockRouterPush, replace: jest.fn(), back: jest.fn() }), +})); + +jest.mock('@tamagui/toast', () => ({ + useToastController: () => ({ show: jest.fn() }), +})); + +const createProps = ( + options: { + tagRepo?: MockTagRepository; + authRepo?: MockAuthRepository; + } = {} +) => { + const tagRepo = options.tagRepo ?? new MockTagRepository(); + const authRepo = options.authRepo ?? new MockAuthRepository(); + return { + authLogoutUsecase: new AuthLogoutUsecase(authRepo), + tagListUsecase: new TagListUsecase(tagRepo, { tags: [] }), + tagDeleteUsecase: new TagDeleteUsecase(tagRepo), + }; +}; + +describe('TagListHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('loading and data states', () => { + it('should show skeleton list during initial loading', async () => { + render(); + expect(screen.getByTestId('skeleton-list')).toBeTruthy(); + await act(async () => { + await flushPromises(); + }); + }); + + it('should show tag list after successful fetch', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'New' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Best Seller' })).toBeTruthy(); + }); + + it('should show the variant count for each tag', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('3 variants')).toBeTruthy(); + expect(screen.getByText('5 variants')).toBeTruthy(); + }); + + it('should show the highlighted flag for each tag', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getAllByText('Highlighted').length).toBe(2); + }); + + it('should show error state when fetch fails', async () => { + const tagRepo = new MockTagRepository(); + tagRepo.setShouldFail(true); + + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Failed to Fetch Tags' })).toBeTruthy(); + }); + + it('should not show skeleton after data is loaded', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByTestId('skeleton-list')).toBeNull(); + }); + + it('should preserve list content during revalidation after delete', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + await user.click(screen.getByRole('button', { name: 'Yes' })); + + expect(screen.queryByTestId('skeleton-list')).toBeNull(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByTestId('skeleton-list')).toBeNull(); + }); + + it('should show empty state when no tags exist', async () => { + const tagRepo = new MockTagRepository(); + tagRepo.tags = []; + + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Oops, Tag is Empty' })).toBeTruthy(); + }); + + it('should show create CTA button in empty state', async () => { + const tagRepo = new MockTagRepository(); + tagRepo.tags = []; + render(); + await act(async () => { await flushPromises(); }); + expect(screen.getByRole('button', { name: 'Create Tag' })).toBeTruthy(); + }); + + it('should navigate to create page when CTA button is pressed', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + tagRepo.tags = []; + render(); + await act(async () => { await flushPromises(); }); + await user.click(screen.getByRole('button', { name: 'Create Tag' })); + expect(mockRouterPush).toHaveBeenCalledWith('/tags/create'); + }); + }); + + describe('delete modal', () => { + it('should not show delete modal initially', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByRole('heading', { name: 'Delete Tag ?' })).toBeNull(); + }); + + it('should show delete modal when delete menu is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + + expect(screen.getByRole('heading', { name: 'Delete Tag ?' })).toBeTruthy(); + }); + + it('should state how many variants carry the tag in the delete modal', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + + expect(screen.getByText(/applied to 3 variants/)).toBeTruthy(); + }); + + it('should hide delete modal when cancel is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + expect(screen.getByRole('heading', { name: 'Delete Tag ?' })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'No' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByRole('heading', { name: 'Delete Tag ?' })).toBeNull(); + }); + + it('should disable Yes button and show spinner during deletion', async () => { + const user = userEvent.setup(); + let resolveDelete!: () => void; + const tagRepo = new MockTagRepository(); + jest.spyOn(tagRepo, 'deleteTagById').mockImplementation( + () => new Promise((resolve) => { resolveDelete = resolve; }) + ); + + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + await user.click(screen.getByRole('button', { name: 'Yes' })); + + expect((screen.getByRole('button', { name: 'Yes' }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('spinner')).toBeTruthy(); + + await act(async () => { + resolveDelete(); + await flushPromises(); + }); + + expect(screen.queryByTestId('spinner')).toBeNull(); + }); + + it('should refetch tag list after successful delete', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'New' })).toBeTruthy(); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + expect(screen.getByRole('heading', { name: 'Delete Tag ?' })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'Yes' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByRole('heading', { name: 'Delete Tag ?' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'New' })).toBeNull(); + expect(screen.getByRole('heading', { name: 'Best Seller' })).toBeTruthy(); + }); + }); + + describe('navigation', () => { + it('should navigate to tag edit page when edit menu is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const editMenuItems = screen.getAllByRole('button', { name: 'Edit' }); + await user.click(editMenuItems[0]); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags/1'); + }); + + it('should navigate to tag assignment page when assign menu is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const assignMenuItems = screen.getAllByRole('button', { + name: 'Assign products', + }); + await user.click(assignMenuItems[0]); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags/1/assign'); + }); + + it('should navigate to tag page when item is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + await user.click(screen.getByRole('heading', { name: 'New' })); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags/1'); + }); + }); + + describe('error recovery', () => { + it('should refetch tags when retry button is pressed', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + tagRepo.setShouldFail(true); + + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Failed to Fetch Tags' })).toBeTruthy(); + + tagRepo.setShouldFail(false); + + await user.click(screen.getByRole('button', { name: 'Retry' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'New' })).toBeTruthy(); + }); + }); +}); diff --git a/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx b/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx new file mode 100644 index 00000000..386114ed --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx @@ -0,0 +1,99 @@ +import { useRouter } from 'solito/router'; +import { + AuthLogoutUsecase, + Tag, + TagDeleteUsecase, + TagListUsecase, +} from '../../../domain'; +import { TagListScreen, TagListScreenProps } from '../../views/screens/pos/TagListScreen'; +import { match, P } from 'ts-pattern'; +import { useCallback, useEffect } from 'react'; +import { useToastController } from '@tamagui/toast'; +import { useUsecase, useAuthLogout } from '../hooks'; +import { useFocusEffect } from '../../../utils'; + +export type TagListHandlerProps = { + authLogoutUsecase: AuthLogoutUsecase; + tagListUsecase: TagListUsecase; + tagDeleteUsecase: TagDeleteUsecase; +}; + +export const TagListHandler = ({ + authLogoutUsecase, + tagListUsecase, + tagDeleteUsecase, +}: TagListHandlerProps) => { + const authLogout = useAuthLogout(authLogoutUsecase); + const tagList = useUsecase(tagListUsecase); + const tagDelete = useUsecase(tagDeleteUsecase); + const router = useRouter(); + const toast = useToastController(); + + useFocusEffect( + useCallback(() => { + tagList.dispatch({ type: 'FETCH' }); + }, [tagList.dispatch]) + ); + + useEffect(() => { + match(tagDelete.state) + .with({ type: 'deletingSuccess' }, () => { + toast.show('Delete Tag Success'); + tagList.dispatch({ type: 'FETCH' }); + }) + .with({ type: 'deletingError' }, () => { + toast.show('Delete Tag Error'); + }) + .otherwise(() => { + // nothing to do + }); + }, [tagDelete.state, tagList, toast]); + + return ( + authLogout.dispatch({ type: 'LOGOUT' })} + onEditMenuPress={(tag: Tag) => + router.push(`/tags/${tag.id}`) + } + onAssignMenuPress={(tag: Tag) => + router.push(`/tags/${tag.id}/assign`) + } + onItemPress={(tag: Tag) => + router.push(`/tags/${tag.id}`) + } + onDeleteMenuPress={(tag: Tag) => + tagDelete.dispatch({ + type: 'SHOW_CONFIRMATION', + tagId: tag.id, + }) + } + onEmptyActionPress={() => router.push('/tags/create')} + onRetryButtonPress={() => tagList.dispatch({ type: 'FETCH' })} + isRevalidating={tagList.state.type === 'revalidating'} + variant={match(tagList.state) + .returnType() + .with({ type: P.union('idle', 'loading') }, () => ({ type: 'loading' })) + .with({ type: P.union('loaded', 'revalidating') }, ({ tags }) => ({ + type: tags.length > 0 ? 'loaded' : 'empty', + tags, + })) + .with({ type: 'error' }, () => ({ type: 'error' })) + .exhaustive()} + isDeleteButtonDisabled={tagDelete.state.type === 'deleting'} + deleteVariantCount={ + tagList.state.tags.find((tag) => tag.id === tagDelete.state.tagId) + ?.variantCount + } + isDeleteModalOpen={match(tagDelete.state.type) + .with( + P.union('shown', 'deleting', 'deletingError', 'deletingSuccess'), + () => true + ) + .otherwise(() => false)} + onDeleteCancel={() => + tagDelete.dispatch({ type: 'HIDE_CONFIRMATION' }) + } + onDeleteConfirm={() => tagDelete.dispatch({ type: 'DELETE' })} + /> + ); +}; diff --git a/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.test.tsx new file mode 100644 index 00000000..0e156eac --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.test.tsx @@ -0,0 +1,363 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TagUpdateHandler } from './TagUpdateHandler'; +import { MockAuthRepository, MockTagRepository } from '../../../data/mock'; +import { AuthLogoutUsecase, TagUpdateUsecase } from '../../../domain'; +import { flushPromises } from '../../../utils/testUtils'; + +const mockRouterPush = jest.fn(); +jest.mock('solito/router', () => ({ + useRouter: () => ({ push: mockRouterPush, replace: jest.fn(), back: jest.fn() }), +})); + +const mockToastShow = jest.fn(); +jest.mock('@tamagui/toast', () => ({ + useToastController: () => ({ show: mockToastShow }), +})); + +const createProps = (options: { + tagId?: number; + shouldFail?: boolean; + preloaded?: boolean; +} = {}) => { + const tagId = options.tagId ?? 1; + const tagRepo = new MockTagRepository(); + if (options.shouldFail) tagRepo.setShouldFail(true); + + const preloadedTag = options.preloaded + ? tagRepo.tags.find((c) => c.id === tagId) ?? null + : null; + + return { + authLogoutUsecase: new AuthLogoutUsecase(new MockAuthRepository()), + tagUpdateUsecase: new TagUpdateUsecase(tagRepo, { + tagId, + tag: preloadedTag, + }), + }; +}; + +describe('TagUpdateHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(async () => { + await act(async () => { + await flushPromises(); + }); + }); + + describe('loading and data states', () => { + it('should show loading state while fetching tag', async () => { + render(); + expect(screen.getByText('Fetching Tag...')).toBeTruthy(); + await act(async () => { + await flushPromises(); + }); + }); + + it('should show the form after tag data loads', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('button', { name: 'Submit' })).toBeTruthy(); + }); + + it('should render pre-filled form when tag is preloaded', async () => { + render(); + expect(screen.getByDisplayValue('New')).toBeTruthy(); + await act(async () => { + await flushPromises(); + }); + }); + + it('should fill the form with fetched values when data loads after mount', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByDisplayValue('New')).toBeTruthy(); + }); + + it('should render the current colour and highlight values', async () => { + render(); + expect( + screen.getByRole('radio', { name: 'Green' }).getAttribute('aria-checked') + ).toBe('true'); + expect( + screen.getByRole('switch').getAttribute('aria-checked') + ).toBe('true'); + await act(async () => { + await flushPromises(); + }); + }); + + it('should show error state when tag fetch fails', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Failed to Fetch Tag' })).toBeTruthy(); + }); + }); + + describe('navigation', () => { + it('should navigate to "/tags" after successful update', async () => { + const user = userEvent.setup(); + render(); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should update tag with the chosen colour and highlight', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + const preloadedTag = tagRepo.tags[0]; + + render( + + ); + + await user.click(screen.getByRole('radio', { name: 'Purple' })); + await user.click(screen.getByRole('switch')); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect( + tagRepo.tags.find((c) => c.id === preloadedTag.id) + ).toMatchObject({ color: 'purple', isHighlighted: false }); + }); + + it('should not navigate when update fails', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + const preloadedTag = tagRepo.tags[0]; + const tagUpdateUsecase = new TagUpdateUsecase(tagRepo, { + tagId: preloadedTag.id, + tag: preloadedTag, + }); + tagRepo.setShouldFail(true); + + render( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it('should not navigate without user interaction', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + }); + + describe('validation', () => { + it('should show error message when name field is empty and submit is clicked', async () => { + const user = userEvent.setup(); + render(); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('String must contain at least 1 character(s)')).toBeTruthy(); + }); + }); + + describe('loading states', () => { + it('should disable submit button and show spinner while updating', async () => { + const user = userEvent.setup(); + let resolveUpdate!: () => void; + const tagRepo = new MockTagRepository(); + jest.spyOn(tagRepo, 'updateTag').mockImplementation( + () => new Promise((resolve) => { resolveUpdate = resolve; }) + ); + const preloadedTag = tagRepo.tags[0]; + + render( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + expect((screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('spinner')).toBeTruthy(); + + await act(async () => { + resolveUpdate(); + await flushPromises(); + }); + + expect(screen.queryByTestId('spinner')).toBeNull(); + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should not disable submit button when form is loaded', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect((screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement).disabled).toBe(false); + }); + }); + + describe('toast notifications', () => { + it('should show toast error message when update fails', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + const preloadedTag = tagRepo.tags[0]; + const tagUpdateUsecase = new TagUpdateUsecase(tagRepo, { + tagId: preloadedTag.id, + tag: preloadedTag, + }); + tagRepo.setShouldFail(true); + + render( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockToastShow).toHaveBeenCalledWith('Update Tag Error'); + }); + }); + + describe('error banner', () => { + it('should show error banner when update fails', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + const preloadedTag = tagRepo.tags[0]; + const tagUpdateUsecase = new TagUpdateUsecase(tagRepo, { + tagId: preloadedTag.id, + tag: preloadedTag, + }); + tagRepo.setShouldFail(true); + + render( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('Failed to submit. Please try again.')).toBeTruthy(); + }); + + it('should not show error banner before any submission', () => { + render(); + expect(screen.queryByText('Failed to submit. Please try again.')).toBeNull(); + }); + }); + + describe('error recovery', () => { + it('should refetch tag when retry button is pressed after error', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + tagRepo.setShouldFail(true); + + render( + + ); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Failed to Fetch Tag' })).toBeTruthy(); + + tagRepo.setShouldFail(false); + + await user.click(screen.getByRole('button', { name: 'Retry' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('button', { name: 'Submit' })).toBeTruthy(); + }); + }); +}); diff --git a/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.tsx b/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.tsx new file mode 100644 index 00000000..d6aa598a --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.tsx @@ -0,0 +1,75 @@ +import { useRouter } from 'solito/router'; +import { AuthLogoutUsecase, TagUpdateUsecase } from '../../../domain'; +import { match, P } from 'ts-pattern'; +import { useEffect } from 'react'; +import { useToastController } from '@tamagui/toast'; +import { useUsecase, useAuthLogout } from '../hooks'; +import { + TagUpdateScreen, + TagUpdateScreenProps, +} from '../../views/screens/pos/TagUpdateScreen'; + +export type TagUpdateHandlerProps = { + authLogoutUsecase: AuthLogoutUsecase; + tagUpdateUsecase: TagUpdateUsecase; +}; + +export const TagUpdateHandler = ({ + authLogoutUsecase, + tagUpdateUsecase, +}: TagUpdateHandlerProps) => { + const authLogout = useAuthLogout(authLogoutUsecase); + const tagUpdate = useUsecase(tagUpdateUsecase); + const router = useRouter(); + const toast = useToastController(); + + useEffect(() => { + if (tagUpdate.state.type === 'submitSuccess') { + toast.show('Update Tag Success'); + router.push('/tags'); + } else if (tagUpdate.state.type === 'submitError') { + toast.show('Update Tag Error'); + } + }, [tagUpdate.state.type, toast, router]); + + return ( + authLogout.dispatch({ type: 'LOGOUT' })} + defaultValues={tagUpdate.state.values} + isSubmitDisabled={ + tagUpdate.state.type === 'submitting' || + tagUpdate.state.type === 'submitSuccess' + } + isSubmitting={tagUpdate.state.type === 'submitting'} + serverError={ + tagUpdate.state.type === 'submitError' + ? 'Failed to submit. Please try again.' + : undefined + } + onSubmit={(values) => tagUpdate.dispatch({ type: 'SUBMIT', values })} + variant={match(tagUpdate.state) + .returnType() + .with({ type: P.union('idle', 'loading') }, () => ({ + type: 'loading', + })) + .with( + { + type: P.union( + 'loaded', + 'submitError', + 'submitSuccess', + 'submitting' + ), + }, + () => ({ + type: 'loaded', + }) + ) + .with({ type: 'error' }, () => ({ + type: 'error', + onRetryButtonPress: () => tagUpdate.dispatch({ type: 'FETCH' }), + })) + .exhaustive()} + /> + ); +}; diff --git a/libs/ui/src/presentation/handlers/pos/VariantCreateHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/VariantCreateHandler.test.tsx index c26d8b51..74901637 100644 --- a/libs/ui/src/presentation/handlers/pos/VariantCreateHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/pos/VariantCreateHandler.test.tsx @@ -6,12 +6,14 @@ import { MockAuthRepository, MockMaterialRepository, MockMaterialListQueryRepository, + MockTagRepository, MockProductRepository, MockVariantRepository, } from '../../../data/mock'; import { AuthLogoutUsecase, MaterialListUsecase, + TagListUsecase, VariantCreateUsecase, } from '../../../domain'; import { flushPromises } from '../../../utils/testUtils'; @@ -52,6 +54,7 @@ const createProps = ( productId, product: preloadedProduct, }), + tagListUsecase: new TagListUsecase(new MockTagRepository(), { tags: [] }), materialListUsecase: new MaterialListUsecase( materialRepo, new MockMaterialListQueryRepository(), @@ -161,6 +164,7 @@ describe('VariantCreateHandler', () => { { { productRepo, { productId: 1, product: null } )} + tagListUsecase={new TagListUsecase(new MockTagRepository(), { tags: [] })} materialListUsecase={new MaterialListUsecase( new MockMaterialRepository(), new MockMaterialListQueryRepository(), @@ -276,4 +282,125 @@ describe('VariantCreateHandler', () => { expect(screen.getByRole('button', { name: 'Submit' })).toBeTruthy(); }); }); + + describe('tags', () => { + const taggedAt = '2024-03-20T00:00:00.000Z'; + + const renderWithTags = (productScopeTagIds: number[]) => { + const variantRepo = new MockVariantRepository(); + const productRepo = new MockProductRepository(); + const tagRepo = new MockTagRepository(); + const product = { + ...productRepo.products[0], + options: [], + tags: tagRepo.tags + .filter(({ id }) => productScopeTagIds.includes(id)) + .map((tag) => ({ + tag, + scope: 'product' as const, + variantIds: [1, 2], + taggedAt, + })), + }; + + render( + + ); + + return variantRepo; + }; + + const submitVariant = async (user: ReturnType) => { + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Oat Milk'); + await user.type(screen.getByRole('textbox', { name: 'Price' }), '100'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await act(async () => { + await flushPromises(); + }); + }; + + it('should render every tag as an unchecked option when the product has no tags', async () => { + renderWithTags([]); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('checkbox', { name: 'New' }).getAttribute('aria-checked')).toBe('false'); + expect(screen.getByRole('checkbox', { name: 'Best Seller' }).getAttribute('aria-checked')).toBe('false'); + }); + + it('should pre-tick the product-scope tags of the parent product', async () => { + renderWithTags([2]); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('checkbox', { name: 'Best Seller' }).getAttribute('aria-checked')).toBe('true'); + expect(screen.getByRole('checkbox', { name: 'New' }).getAttribute('aria-checked')).toBe('false'); + }); + + it('should submit the pre-ticked tag ids when left untouched', async () => { + const user = userEvent.setup(); + const variantRepo = renderWithTags([2]); + + await act(async () => { + await flushPromises(); + }); + await submitVariant(user); + + expect(variantRepo.lastSubmittedValues?.tagIds).toEqual([2]); + }); + + it('should submit an empty tagIds when the pre-ticked tag is unticked', async () => { + const user = userEvent.setup(); + const variantRepo = renderWithTags([2]); + + await act(async () => { + await flushPromises(); + }); + await user.click(screen.getByRole('checkbox', { name: 'Best Seller' })); + await submitVariant(user); + + expect(variantRepo.lastSubmittedValues?.tagIds).toEqual([]); + }); + + it('should submit a tag that was ticked by the user', async () => { + const user = userEvent.setup(); + const variantRepo = renderWithTags([]); + + await act(async () => { + await flushPromises(); + }); + await user.click(screen.getByRole('checkbox', { name: 'New' })); + expect(screen.getByRole('checkbox', { name: 'New' }).getAttribute('aria-checked')).toBe('true'); + await submitVariant(user); + + expect(variantRepo.lastSubmittedValues?.tagIds).toEqual([1]); + }); + + it('should fetch the tag options when none are preloaded', async () => { + render(); + expect(screen.queryByRole('checkbox', { name: 'New' })).toBeNull(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('checkbox', { name: 'New' })).toBeTruthy(); + }); + }); }); diff --git a/libs/ui/src/presentation/handlers/pos/VariantCreateHandler.tsx b/libs/ui/src/presentation/handlers/pos/VariantCreateHandler.tsx index eb5f6a80..b7a150d4 100644 --- a/libs/ui/src/presentation/handlers/pos/VariantCreateHandler.tsx +++ b/libs/ui/src/presentation/handlers/pos/VariantCreateHandler.tsx @@ -6,6 +6,7 @@ import { useUsecase, useAuthLogout, useMaterialList } from '../hooks'; import { AuthLogoutUsecase, MaterialListUsecase, + TagListUsecase, VariantCreateUsecase, } from '../../../domain'; import { @@ -16,17 +17,20 @@ import { export type VariantCreateHandlerProps = { variantCreateUsecase: VariantCreateUsecase; materialListUsecase: MaterialListUsecase; + tagListUsecase: TagListUsecase; authLogoutUsecase: AuthLogoutUsecase; }; export const VariantCreateHandler = ({ variantCreateUsecase, materialListUsecase, + tagListUsecase, authLogoutUsecase, }: VariantCreateHandlerProps) => { const router = useRouter(); const variantCreate = useUsecase(variantCreateUsecase); const materialList = useMaterialList(materialListUsecase); + const tagList = useUsecase(tagListUsecase); const authLogout = useAuthLogout(authLogoutUsecase); const toast = useToastController(); @@ -83,6 +87,7 @@ export const VariantCreateHandler = ({ })) .exhaustive()} product={variantCreate.state.product} + tags={tagList.state.tags} materialList={{ currentPage: materialList.state.page, itemPerPage: materialList.state.itemPerPage, diff --git a/libs/ui/src/presentation/handlers/pos/VariantUpdateHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/VariantUpdateHandler.test.tsx index 16239409..0393716c 100644 --- a/libs/ui/src/presentation/handlers/pos/VariantUpdateHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/pos/VariantUpdateHandler.test.tsx @@ -6,12 +6,14 @@ import { MockAuthRepository, MockMaterialRepository, MockMaterialListQueryRepository, + MockTagRepository, MockProductRepository, MockVariantRepository, } from '../../../data/mock'; import { AuthLogoutUsecase, MaterialListUsecase, + TagListUsecase, VariantUpdateUsecase, } from '../../../domain'; import { flushPromises } from '../../../utils/testUtils'; @@ -59,6 +61,7 @@ const createProps = ( variant: preloadedVariant, product: preloadedProduct, }), + tagListUsecase: new TagListUsecase(new MockTagRepository(), { tags: [] }), materialListUsecase: new MaterialListUsecase( materialRepo, new MockMaterialListQueryRepository(), @@ -175,6 +178,7 @@ describe('VariantUpdateHandler', () => { { { new MockProductRepository(), { variantId: 1, productId: 1, variant: null, product: null } )} + tagListUsecase={new TagListUsecase(new MockTagRepository(), { tags: [] })} materialListUsecase={new MaterialListUsecase( new MockMaterialRepository(), new MockMaterialListQueryRepository(), @@ -295,4 +301,81 @@ describe('VariantUpdateHandler', () => { expect(screen.getByRole('button', { name: 'Submit' })).toBeTruthy(); }); }); + + describe('tags', () => { + const renderWithTaggedVariant = (taggedTagIds: number[]) => { + const variantRepo = new MockVariantRepository(); + const productRepo = new MockProductRepository(); + const tagRepo = new MockTagRepository(); + const variant = { + ...variantRepo.variants[0], + tags: tagRepo.tags + .filter(({ id }) => taggedTagIds.includes(id)) + .map((tag) => ({ tag, taggedAt: '2024-03-20T00:00:00.000Z' })), + }; + const product = { ...productRepo.products[0], options: [] }; + + render( + + ); + + return variantRepo; + }; + + it('should tick the tags the variant already carries', async () => { + renderWithTaggedVariant([1]); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('checkbox', { name: 'New' }).getAttribute('aria-checked')).toBe('true'); + expect(screen.getByRole('checkbox', { name: 'Best Seller' }).getAttribute('aria-checked')).toBe('false'); + }); + + it('should submit the existing tag ids when the field is left untouched', async () => { + const user = userEvent.setup(); + const variantRepo = renderWithTaggedVariant([1, 2]); + + await act(async () => { + await flushPromises(); + }); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await act(async () => { + await flushPromises(); + }); + + expect(variantRepo.lastSubmittedValues?.tagIds).toEqual([1, 2]); + }); + + it('should submit the remaining tag ids after one is unticked', async () => { + const user = userEvent.setup(); + const variantRepo = renderWithTaggedVariant([1, 2]); + + await act(async () => { + await flushPromises(); + }); + await user.click(screen.getByRole('checkbox', { name: 'New' })); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await act(async () => { + await flushPromises(); + }); + + expect(variantRepo.lastSubmittedValues?.tagIds).toEqual([2]); + }); + }); }); diff --git a/libs/ui/src/presentation/handlers/pos/VariantUpdateHandler.tsx b/libs/ui/src/presentation/handlers/pos/VariantUpdateHandler.tsx index 3d07570f..041c27b3 100644 --- a/libs/ui/src/presentation/handlers/pos/VariantUpdateHandler.tsx +++ b/libs/ui/src/presentation/handlers/pos/VariantUpdateHandler.tsx @@ -6,6 +6,7 @@ import { useUsecase, useAuthLogout, useMaterialList } from '../hooks'; import { AuthLogoutUsecase, MaterialListUsecase, + TagListUsecase, VariantUpdateUsecase, } from '../../../domain'; import { @@ -16,17 +17,20 @@ import { export type VariantUpdateHandlerProps = { variantUpdateUsecase: VariantUpdateUsecase; materialListUsecase: MaterialListUsecase; + tagListUsecase: TagListUsecase; authLogoutUsecase: AuthLogoutUsecase; }; export const VariantUpdateHandler = ({ variantUpdateUsecase, materialListUsecase, + tagListUsecase, authLogoutUsecase, }: VariantUpdateHandlerProps) => { const router = useRouter(); const variantUpdate = useUsecase(variantUpdateUsecase); const materialList = useMaterialList(materialListUsecase); + const tagList = useUsecase(tagListUsecase); const authLogout = useAuthLogout(authLogoutUsecase); const toast = useToastController(); @@ -83,6 +87,7 @@ export const VariantUpdateHandler = ({ })) .exhaustive()} product={variantUpdate.state.product} + tags={tagList.state.tags} materialList={{ currentPage: materialList.state.page, itemPerPage: materialList.state.itemPerPage, diff --git a/libs/ui/src/presentation/handlers/pos/index.ts b/libs/ui/src/presentation/handlers/pos/index.ts index ee1e8d89..b79128df 100644 --- a/libs/ui/src/presentation/handlers/pos/index.ts +++ b/libs/ui/src/presentation/handlers/pos/index.ts @@ -5,6 +5,9 @@ export * from './BudgetUpdateHandler'; export * from './CategoryCreateHandler'; export * from './CategoryListHandler'; export * from './CategoryUpdateHandler'; +export * from './TagCreateHandler'; +export * from './TagListHandler'; +export * from './TagUpdateHandler'; export * from './CouponCreateHandler'; export * from './CouponListHandler'; export * from './CouponUpdateHandler'; @@ -57,3 +60,4 @@ export * from './StockCheckCreateHandler'; export * from './StockCheckUpdateHandler'; export * from './PurchaseListHandler'; export * from './AvailabilityHandler'; +export * from './TagAssignmentHandler'; diff --git a/libs/ui/src/presentation/views/components/base/Sidebar/Sidebar.state.tsx b/libs/ui/src/presentation/views/components/base/Sidebar/Sidebar.state.tsx index 58767306..f0574e2c 100644 --- a/libs/ui/src/presentation/views/components/base/Sidebar/Sidebar.state.tsx +++ b/libs/ui/src/presentation/views/components/base/Sidebar/Sidebar.state.tsx @@ -39,6 +39,7 @@ const items: MenuItem[] = [ icon: Box, subItems: [ { title: 'Categories', path: '/categories' }, + { title: 'Tags', path: '/tags' }, { title: 'Products', path: '/products' }, { title: 'Materials', path: '/materials' }, { title: 'Suppliers', path: '/suppliers' }, diff --git a/libs/ui/src/presentation/views/components/cart/CartLineItem.stories.tsx b/libs/ui/src/presentation/views/components/cart/CartLineItem.stories.tsx index 72daeddf..533bcb5f 100644 --- a/libs/ui/src/presentation/views/components/cart/CartLineItem.stories.tsx +++ b/libs/ui/src/presentation/views/components/cart/CartLineItem.stories.tsx @@ -21,6 +21,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const variant = { @@ -41,6 +42,7 @@ const variant = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const item = { diff --git a/libs/ui/src/presentation/views/components/index.ts b/libs/ui/src/presentation/views/components/index.ts index a40576d6..a6de8df5 100644 --- a/libs/ui/src/presentation/views/components/index.ts +++ b/libs/ui/src/presentation/views/components/index.ts @@ -3,6 +3,7 @@ export * from './kds'; export * from './base'; export * from './budgets'; export * from './categories'; +export * from './tags'; export * from './coupons'; export * from './tickets'; export * from './tables'; diff --git a/libs/ui/src/presentation/views/components/menu/CategoryChipList.stories.tsx b/libs/ui/src/presentation/views/components/menu/CategoryChipList.stories.tsx index c0ee7aa7..27547f9e 100644 --- a/libs/ui/src/presentation/views/components/menu/CategoryChipList.stories.tsx +++ b/libs/ui/src/presentation/views/components/menu/CategoryChipList.stories.tsx @@ -1,6 +1,27 @@ import type { Meta, StoryObj } from '@storybook/react'; import { CategoryChipList } from './CategoryChipList'; +const tags = [ + { + id: 1, + name: 'New', + color: 'green' as const, + isHighlighted: true, + sortOrder: 1, + variantCount: 2, + createdAt: '', + }, + { + id: 2, + name: 'Best Seller', + color: 'orange' as const, + isHighlighted: true, + sortOrder: 2, + variantCount: 3, + createdAt: '', + }, +]; + const meta: Meta = { title: 'Components/Menu/CategoryChipList', component: CategoryChipList, @@ -27,3 +48,19 @@ export const CategorySelected: Story = { selectedCategoryId: 2, }, }; + +export const WithTagChips: Story = { + args: { + selectedCategoryId: null, + tags, + selectedTagId: null, + }, +}; + +export const TagSelected: Story = { + args: { + selectedCategoryId: null, + tags, + selectedTagId: 2, + }, +}; diff --git a/libs/ui/src/presentation/views/components/menu/CategoryChipList.tsx b/libs/ui/src/presentation/views/components/menu/CategoryChipList.tsx index 565e8a18..860137b9 100644 --- a/libs/ui/src/presentation/views/components/menu/CategoryChipList.tsx +++ b/libs/ui/src/presentation/views/components/menu/CategoryChipList.tsx @@ -1,28 +1,58 @@ import { Button, ScrollView, XStack } from 'tamagui'; import { Category } from '../../../../domain/entities/Category'; +import { Tag } from '../../../../domain/entities/Tag'; +import { tagColorBackground, tagColorForeground } from '../tags/tagColors'; export type CategoryChipListProps = { categories: Category[]; selectedCategoryId: number | null; onSelectCategory: (categoryId: number | null) => void; + tags?: Tag[]; + selectedTagId?: number | null; + onSelectTag?: (tagId: number) => void; }; export const CategoryChipList = ({ categories, selectedCategoryId, onSelectCategory, + tags = [], + selectedTagId = null, + onSelectTag, }: CategoryChipListProps) => { + const isAllSelected = selectedCategoryId === null && selectedTagId === null; + return ( + {tags.map((tag) => { + const isSelected = selectedTagId === tag.id; + return ( + + ); + })} {categories.map((category) => ( + + + + + + + + ); +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagFormView.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagFormView.stories.tsx new file mode 100644 index 00000000..a8da29de --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagFormView.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagFormView } from './TagFormView'; + +const meta: Meta = { + title: 'Components/Tags/TagFormView', + component: TagFormView, +}; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + args: { + variant: { type: 'loaded' }, + defaultValues: { + name: '', + color: 'gray', + isHighlighted: false, + sortOrder: 0, + }, + onSubmit: fn(), + isSubmitDisabled: false, + isSubmitting: false, + }, +}; + +export const Populated: Story = { + args: { + ...Loaded.args, + defaultValues: { + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 2, + }, + }, +}; + +export const Loading: Story = { + args: { + ...Loaded.args, + variant: { type: 'loading' }, + isSubmitDisabled: true, + }, +}; + +export const Error: Story = { + args: { + ...Loaded.args, + variant: { type: 'error', onRetryButtonPress: fn() }, + isSubmitDisabled: true, + }, +}; + +export const SubmitDisabled: Story = { + args: { + ...Loaded.args, + isSubmitDisabled: true, + }, +}; + +export const Submitting: Story = { + args: { + ...Loaded.args, + isSubmitDisabled: true, + isSubmitting: true, + }, +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagFormView.tsx b/libs/ui/src/presentation/views/components/tags/TagFormView.tsx new file mode 100644 index 00000000..552121dd --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagFormView.tsx @@ -0,0 +1,61 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Spinner } from 'tamagui'; +import { + Field, + FormErrorBanner, + InputText, + InputNumber, + Switch, + FormView, + FormVariant, +} from '../base'; +import { TagForm, tagFormSchema } from '../../../../domain'; +import { TagColorPicker } from './TagColorPicker'; + +const tagFormResolver = zodResolver(tagFormSchema); + +export type TagFormViewProps = { + variant: FormVariant; + defaultValues: TagForm; + onSubmit: (values: TagForm) => void; + isSubmitDisabled: boolean; + isSubmitting: boolean; + serverError?: string; +}; + +export const TagFormView = (props: TagFormViewProps) => ( + + {(form) => ( + <> + + + + + + + + + + + + + + + + )} + +); diff --git a/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx new file mode 100644 index 00000000..e004b77c --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagList } from './TagList'; +import { mockTags } from '../../../../../.storybook/mocks/mockData'; + +const defaultArgs = { + onRetryButtonPress: fn(), + onDeleteMenuPress: fn(), + onEditMenuPress: fn(), + onAssignMenuPress: fn(), + onItemPress: fn(), +}; + +const meta: Meta = { + title: 'Components/Tags/TagList', + component: TagList, + args: defaultArgs, +}; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + args: { + variant: { type: 'loaded', tags: mockTags }, + }, +}; + +export const Loading: Story = { + args: { + variant: { type: 'loading' }, + }, +}; + +export const Empty: Story = { + args: { + variant: { type: 'empty' }, + }, +}; + +export const Error: Story = { + args: { + variant: { type: 'error' }, + }, +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagList.tsx b/libs/ui/src/presentation/views/components/tags/TagList.tsx new file mode 100644 index 00000000..4b0df871 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagList.tsx @@ -0,0 +1,75 @@ +import { Spinner, YStack } from 'tamagui'; +import { FlatList } from 'react-native'; +import { match } from 'ts-pattern'; +import { EmptyView, ErrorView, SkeletonList } from '../base'; +import { Tag } from '../../../../domain'; +import { TagListItem } from './TagListItem'; + +export type TagListProps = { + onRetryButtonPress: () => void; + onEmptyActionPress?: () => void; + onDeleteMenuPress: (tag: Tag) => void; + onEditMenuPress: (tag: Tag) => void; + onAssignMenuPress: (tag: Tag) => void; + onItemPress: (tag: Tag) => void; + isRevalidating?: boolean; + variant: + | { type: 'loading' } + | { type: 'error' } + | { type: 'empty' } + | { type: 'loaded'; tags: Tag[] }; +}; + +export const TagList = ({ + onRetryButtonPress, + onEmptyActionPress, + onDeleteMenuPress, + onEditMenuPress, + onAssignMenuPress, + onItemPress, + isRevalidating, + variant, +}: TagListProps) => { + return ( + + {isRevalidating && } + {match(variant) + .with({ type: 'loading' }, () => ) + .with({ type: 'empty' }, () => ( + + )) + .with({ type: 'loaded' }, ({ tags }) => ( + ( + onDeleteMenuPress(item)} + onEditMenuPress={() => onEditMenuPress(item)} + onAssignMenuPress={() => onAssignMenuPress(item)} + onPress={() => onItemPress(item)} + /> + )} + ItemSeparatorComponent={() => } + /> + )) + .with({ type: 'error' }, () => ( + + )) + .exhaustive()} + + ); +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx new file mode 100644 index 00000000..f551fc59 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagListItem } from './TagListItem'; + +const meta: Meta = { + title: 'Components/Tags/TagListItem', + component: TagListItem, + args: { + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + variantCount: 5, + onEditMenuPress: fn(), + onAssignMenuPress: fn(), + onDeleteMenuPress: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Highlighted: Story = {}; + +export const NotHighlighted: Story = { + args: { name: 'Vegan', color: 'blue', isHighlighted: false, variantCount: 1 }, +}; + +export const Unassigned: Story = { + args: { variantCount: 0 }, +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagListItem.tsx b/libs/ui/src/presentation/views/components/tags/TagListItem.tsx new file mode 100644 index 00000000..e267a4ac --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagListItem.tsx @@ -0,0 +1,52 @@ +import { ListChecks, Pencil, Trash } from '@tamagui/lucide-icons'; +import { XStackProps } from 'tamagui'; +import { ListItem } from '../base'; +import { TagColor } from '../../../../domain'; +import { TagColorPill } from './TagColorPill'; + +export type TagListItemProps = { + name: string; + color: TagColor; + isHighlighted: boolean; + variantCount: number; + onAssignMenuPress: () => void; + onEditMenuPress: () => void; + onDeleteMenuPress: () => void; +} & XStackProps; + +export const TagListItem = ({ + name, + color, + isHighlighted, + variantCount, + onDeleteMenuPress, + onEditMenuPress, + onAssignMenuPress, + ...xStackProps +}: TagListItemProps) => { + return ( + } + subtitle={`${variantCount} ${ + variantCount === 1 ? 'variant' : 'variants' + }`} + footerItems={[ + { + label: 'Order app section', + value: isHighlighted ? 'Highlighted' : 'Not highlighted', + }, + ]} + menus={[ + { + title: 'Assign products', + icon: ListChecks, + onPress: onAssignMenuPress, + }, + { title: 'Edit', icon: Pencil, onPress: onEditMenuPress }, + { title: 'Delete', icon: Trash, onPress: onDeleteMenuPress }, + ]} + {...xStackProps} + /> + ); +}; diff --git a/libs/ui/src/presentation/views/components/tags/index.ts b/libs/ui/src/presentation/views/components/tags/index.ts new file mode 100644 index 00000000..78a8d4b5 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/index.ts @@ -0,0 +1,10 @@ +export * from './TagBadge'; +export * from './TagColorPicker'; +export * from './TagColorPill'; +export * from './TagDeleteAlert'; +export * from './TagFormView'; +export * from './TagList'; +export * from './TagListItem'; +export * from './tagColors'; +export * from './TagAssignmentList'; +export * from './ProductTagBadges'; diff --git a/libs/ui/src/presentation/views/components/tags/tagColors.ts b/libs/ui/src/presentation/views/components/tags/tagColors.ts new file mode 100644 index 00000000..e5966028 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/tagColors.ts @@ -0,0 +1,15 @@ +import { TagColor } from '../../../../domain'; + +export const tagColorLabels: Record = { + red: 'Red', + orange: 'Orange', + yellow: 'Yellow', + green: 'Green', + blue: 'Blue', + purple: 'Purple', + pink: 'Pink', + gray: 'Gray', +}; + +export const tagColorBackground = (color: TagColor) => `$${color}5` as const; +export const tagColorForeground = (color: TagColor) => `$${color}11` as const; diff --git a/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.stories.tsx b/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.stories.tsx index 11b861c0..e01e2cb2 100644 --- a/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.stories.tsx +++ b/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.stories.tsx @@ -228,3 +228,33 @@ export const SelectingOptionsWithCappedStepper: Story = { amount: 3, }, }; + +const taggedProducts: Product[] = [ + { + ...mockProducts[0], + tags: [ + { + tag: { + id: 1, + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 1, + variantCount: 2, + createdAt: '2024-01-01T00:00:00.000Z', + }, + scope: 'product', + variantIds: [1, 2], + taggedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }, + ...mockProducts.slice(1), +]; + +export const WithTagBadges: Story = { + args: { + variant: { type: 'loaded' }, + products: taggedProducts, + }, +}; diff --git a/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.tsx b/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.tsx index 609b725c..3204418b 100644 --- a/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.tsx +++ b/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.tsx @@ -356,6 +356,7 @@ export const TransactionItemSelect = ({ style={{ flex: 1 }} name={item.name} imageUrl={item.imageUrl} + tags={item.tags} onPress={ isSoldOut ? undefined diff --git a/libs/ui/src/presentation/views/components/transactions/TransactionVerificationSheet.stories.tsx b/libs/ui/src/presentation/views/components/transactions/TransactionVerificationSheet.stories.tsx index e1ce47d5..83e67780 100644 --- a/libs/ui/src/presentation/views/components/transactions/TransactionVerificationSheet.stories.tsx +++ b/libs/ui/src/presentation/views/components/transactions/TransactionVerificationSheet.stories.tsx @@ -25,12 +25,14 @@ const mockVariant = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }, createdAt: '2024-01-20T10:00:00.000Z', values: [], pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const mockTransaction: Transaction = { diff --git a/libs/ui/src/presentation/views/components/variants/VariantFormView.stories.tsx b/libs/ui/src/presentation/views/components/variants/VariantFormView.stories.tsx index 99522939..93a214a0 100644 --- a/libs/ui/src/presentation/views/components/variants/VariantFormView.stories.tsx +++ b/libs/ui/src/presentation/views/components/variants/VariantFormView.stories.tsx @@ -3,17 +3,23 @@ import { fn } from '@storybook/test'; import { YStack, Text } from 'tamagui'; import { VariantFormView } from './VariantFormView'; import type { VariantForm } from '../../../../domain'; -import { mockProduct, mockMaterial } from '../../../../../.storybook/mocks/mockData'; +import { + mockProduct, + mockMaterial, + mockTags, +} from '../../../../../.storybook/mocks/mockData'; const defaultValues: VariantForm = { name: '', price: 0, description: '', recipe: '', + imageUrl: '', materials: [], productId: 1, values: [], pricingTiers: [], + tagIds: [], }; const meta: Meta = { @@ -30,6 +36,7 @@ export const Loaded: Story = { defaultValues, onSubmit: fn(), product: mockProduct, + tags: mockTags, isSubmitDisabled: false, isSubmitting: false, MaterialList: () => ( @@ -49,10 +56,12 @@ export const Populated: Story = { description: 'Iced version, regular size', recipe: '- Use regular ice, not crushed.\n- Standard 250ml cup.\n- No extra syrup unless requested.', + imageUrl: 'https://example.com/iced-regular.jpg', materials: [{ materialId: 1, amount: 0.015, material: mockMaterial }], productId: 1, values: [{ optionValueId: 1 }], pricingTiers: [], + tagIds: [2], }, }, }; @@ -62,6 +71,7 @@ export const Loading: Story = { ...Loaded.args, variant: { type: 'loading' }, product: null, + tags: [], isSubmitDisabled: true, MaterialList: () => null, }, @@ -72,6 +82,7 @@ export const Error: Story = { ...Loaded.args, variant: { type: 'error', onRetryButtonPress: fn() }, product: null, + tags: [], isSubmitDisabled: true, MaterialList: () => null, }, diff --git a/libs/ui/src/presentation/views/components/variants/VariantFormView.tsx b/libs/ui/src/presentation/views/components/variants/VariantFormView.tsx index 640884f3..0fb24213 100644 --- a/libs/ui/src/presentation/views/components/variants/VariantFormView.tsx +++ b/libs/ui/src/presentation/views/components/variants/VariantFormView.tsx @@ -27,9 +27,15 @@ import { XStack, YStack, } from 'tamagui'; -import { Plus, Trash } from '@tamagui/lucide-icons'; +import { Check, Plus, Trash } from '@tamagui/lucide-icons'; import { MaterialListItem } from '../materials'; -import { Material, Product, VariantForm, variantFormSchema } from '../../../../domain'; +import { + Material, + Product, + Tag, + VariantForm, + variantFormSchema, +} from '../../../../domain'; import { UseFieldArrayReturn } from 'react-hook-form'; import { ReactNode } from 'react'; @@ -40,6 +46,7 @@ export type VariantFormViewProps = { defaultValues: VariantForm; onSubmit: (values: VariantForm) => void; product: Product | null; + tags: Tag[]; isSubmitDisabled: boolean; isSubmitting: boolean; MaterialList: ( @@ -119,7 +126,50 @@ export const VariantFormView = (props: VariantFormViewProps) => { One short line shown to customers in the order app. + + + + Optional. Leave empty to use the product image. + + + {props.tags.length > 0 && ( + + Tags + + + {([tagIds]) => ( + + {props.tags.map((tag) => { + const isSelected = tagIds.includes(tag.id); + return ( + + ); + })} + + )} + + + )} diff --git a/libs/ui/src/presentation/views/screens/order/CartItemEditScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/CartItemEditScreen.stories.tsx index 17c28d6c..c88a7f69 100644 --- a/libs/ui/src/presentation/views/screens/order/CartItemEditScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/CartItemEditScreen.stories.tsx @@ -21,6 +21,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const variant = { @@ -47,6 +48,7 @@ const variant = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const item = { diff --git a/libs/ui/src/presentation/views/screens/order/CartScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/CartScreen.stories.tsx index c94f1a6e..d5ac7065 100644 --- a/libs/ui/src/presentation/views/screens/order/CartScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/CartScreen.stories.tsx @@ -21,6 +21,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const variant = { @@ -41,6 +42,7 @@ const variant = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const cart = { diff --git a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx index fee834e8..0477dfe4 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx @@ -37,6 +37,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const esKopiSusuDenganEs = { @@ -67,6 +68,7 @@ const nasiGoreng = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const softCookies = { @@ -91,6 +93,7 @@ const softCookies = { isAvailable: true, availabilityTracking: 'variant' as const, isSellable: true, + tags: [], }; const meta: Meta = { @@ -292,3 +295,31 @@ export const LockedByPendingPayment: Story = { }, }, }; + +export const ReadyWithVariantTags: Story = { + args: { + variant: { + type: 'ready', + product: esKopiSusu, + price: 18000, + variantErrorMessage: null, + isVariantSellable: true, + variantTags: [ + { + tag: { + id: 1, + name: 'New', + color: 'green', + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', + }, + taggedAt: '2024-03-20T00:00:00.000Z', + }, + ], + }, + ctaState: 'ready', + selectedOptionValueIds: [1], + }, +}; diff --git a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.tsx b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.tsx index 3d513ac5..b57624a7 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.tsx @@ -10,6 +10,7 @@ import { } from 'tamagui'; import { match, P } from 'ts-pattern'; import { Product } from '../../../../domain/entities/Product'; +import { VariantTag } from '../../../../domain/entities/Tag'; import { formatRupiah } from '../../../../utils/currency'; import { ErrorView } from '../../components/base/ErrorView'; import { LoadingView } from '../../components/base/LoadingView'; @@ -21,6 +22,7 @@ import { import { AmountStepper } from '../../components/menu/AmountStepper'; import { MenuItemThumbnail } from '../../components/menu/MenuItemThumbnail'; import { OptionValueChipGroup } from '../../components/menu/OptionValueChipGroup'; +import { TagBadge } from '../../components/tags/TagBadge'; export type MenuItemDetailScreenVariant = | { type: 'loading' } @@ -31,6 +33,7 @@ export type MenuItemDetailScreenVariant = price: number | null; variantErrorMessage: string | null; isVariantSellable: boolean | null; + variantTags?: VariantTag[]; remainingQuantity?: number; }; @@ -104,6 +107,7 @@ export const MenuItemDetailScreen = ({ price, variantErrorMessage, isVariantSellable, + variantTags, remainingQuantity, }) => ( @@ -120,6 +124,23 @@ export const MenuItemDetailScreen = ({ {product.name} + {variantTags && variantTags.length > 0 ? ( + + {[...variantTags] + .sort( + (a, b) => + a.tag.sortOrder - b.tag.sortOrder || + a.tag.id - b.tag.id + ) + .map(({ tag }) => ( + + ))} + + ) : null} {product.description ? ( {product.description} diff --git a/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx index b42c9c0e..b8d82b6c 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx @@ -28,6 +28,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const nasiGoreng = { @@ -43,6 +44,7 @@ const nasiGoreng = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const meta: Meta = { @@ -65,14 +67,24 @@ const meta: Meta = { onSelectCategory: () => { // Storybook action stand-in }, + chipTags: [], + selectedTagId: null, + onSelectTag: () => { + // Storybook action stand-in + }, onRetryButtonPress: () => { // Storybook action stand-in }, onItemPress: () => { // Storybook action stand-in }, + highlightSections: [], + onHighlightEntryPress: () => { + // Storybook action stand-in + }, startingPriceByProductId: { 1: 18000, 2: 25000 }, matchedLabelsByProductId: {}, + variantNameById: {}, itemDetail: null, cancelConfirmation: { isOpen: false, @@ -107,6 +119,93 @@ export const Loaded: Story = { }, }; +const newTag = { + id: 1, + name: 'New', + color: 'green' as const, + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const bestSellerTag = { + id: 2, + name: 'Best Seller', + color: 'orange' as const, + isHighlighted: true, + sortOrder: 2, + variantCount: 2, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const pancong = { + ...nasiGoreng, + id: 3, + name: 'Pancong', + imageUrl: 'https://picsum.photos/200/200', +}; + +const pancongIceCream = { + id: 4, + name: 'Ice Cream', + price: 15000, + imageUrl: 'https://picsum.photos/200/201', + materials: [], + product: pancong, + createdAt: '2024-03-20T00:00:00.000Z', + values: [], + pricingTiers: [], + isAvailable: true, + isSellable: true, + tags: [], +}; + +export const WithHighlightSections: Story = { + args: { + ...Loaded.args, + highlightSections: [ + { + tag: newTag, + entries: [ + { kind: 'variant', product: pancong, variant: pancongIceCream }, + ], + }, + { + tag: bestSellerTag, + entries: [ + { kind: 'product', product: esKopiSusu }, + { kind: 'product', product: nasiGoreng }, + ], + }, + ], + }, +}; + +export const WithTagChips: Story = { + args: { + ...WithHighlightSections.args, + chipTags: [newTag, bestSellerTag], + }, +}; + +export const TagSelected: Story = { + args: { + ...Loaded.args, + chipTags: [newTag, bestSellerTag], + selectedTagId: bestSellerTag.id, + variant: { + type: 'tagLoaded', + tag: bestSellerTag, + entries: [ + { kind: 'product', product: esKopiSusu }, + { kind: 'product', product: nasiGoreng }, + { kind: 'variant', product: pancong, variant: pancongIceCream }, + ], + }, + }, +}; + export const Searching: Story = { args: { ...Loaded.args, diff --git a/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx b/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx index 2d22dbf9..a2c67147 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx @@ -3,6 +3,11 @@ import { Input, ScrollView, Spinner, Text, XStack, YStack } from 'tamagui'; import { match } from 'ts-pattern'; import { Category } from '../../../../domain/entities/Category'; import { Product } from '../../../../domain/entities/Product'; +import { Tag } from '../../../../domain/entities/Tag'; +import { + TagHighlight, + TagHighlightEntry, +} from '../../../../utils/buildTagHighlights'; import { EmptyView } from '../../components/base/EmptyView'; import { ErrorView } from '../../components/base/ErrorView'; import { Focusable } from '../../components/base/Focusable'; @@ -12,6 +17,8 @@ import { PaymentCancelAlertProps, } from '../../components/checkout/PaymentCancelAlert'; import { CategoryChipList } from '../../components/menu/CategoryChipList'; +import { MenuHighlightEntryCard } from '../../components/menu/MenuHighlightEntryCard'; +import { MenuHighlightSection } from '../../components/menu/MenuHighlightSection'; import { MenuProductCard } from '../../components/menu/MenuProductCard'; import { MenuItemDetailScreen, @@ -28,7 +35,8 @@ export type MenuListScreenVariant = | { type: 'loading' } | { type: 'error' } | { type: 'empty' } - | { type: 'loaded'; groups: MenuListScreenGroup[] }; + | { type: 'loaded'; groups: MenuListScreenGroup[] } + | { type: 'tagLoaded'; tag: Tag; entries: TagHighlightEntry[] }; export type MenuListScreenProps = { tableVariant: TableResolveScreenProps['variant']; @@ -42,11 +50,17 @@ export type MenuListScreenProps = { chipCategories: Category[]; selectedCategoryId: number | null; onSelectCategory: (categoryId: number | null) => void; + chipTags: Tag[]; + selectedTagId: number | null; + onSelectTag: (tagId: number) => void; variant: MenuListScreenVariant; onRetryButtonPress: () => void; onItemPress: (product: Product) => void; + highlightSections: TagHighlight[]; + onHighlightEntryPress: (entry: TagHighlightEntry) => void; startingPriceByProductId: Record; matchedLabelsByProductId: Record; + variantNameById: Record; itemDetail: (MenuItemDetailScreenProps & { isOpen: true }) | null; cancelConfirmation: PaymentCancelAlertProps; }; @@ -63,11 +77,17 @@ export const MenuListScreen = ({ chipCategories, selectedCategoryId, onSelectCategory, + chipTags, + selectedTagId, + onSelectTag, variant, onRetryButtonPress, onItemPress, + highlightSections, + onHighlightEntryPress, startingPriceByProductId, matchedLabelsByProductId, + variantNameById, itemDetail, cancelConfirmation, }: MenuListScreenProps) => { @@ -97,11 +117,14 @@ export const MenuListScreen = ({ {isSearching && } - {chipCategories.length > 0 && ( + {(chipCategories.length > 0 || chipTags.length > 0) && ( )} @@ -128,6 +151,15 @@ export const MenuListScreen = ({ .with({ type: 'loaded' }, ({ groups }) => ( + {highlightSections.map(({ tag, entries }) => ( + + ))} {groups.map(({ category, products }) => ( @@ -144,9 +176,8 @@ export const MenuListScreen = ({ startingPrice={ startingPriceByProductId[product.id] ?? null } - matchedLabels={ - matchedLabelsByProductId[product.id] - } + matchedLabels={matchedLabelsByProductId[product.id]} + variantNameById={variantNameById} onPress={() => onItemPress(product)} /> @@ -157,6 +188,25 @@ export const MenuListScreen = ({ )) + .with({ type: 'tagLoaded' }, ({ entries }) => ( + + + {entries.map((entry) => ( + + ))} + + + )) .exhaustive()} diff --git a/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.stories.tsx new file mode 100644 index 00000000..a18c3711 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagAssignmentScreen } from './TagAssignmentScreen'; + +const defaultArgs = { + onLogoutPress: fn(), + tagName: 'New', + searchValue: '', + onSearchValueChange: fn(), + onRetryButtonPress: fn(), + onProductToggle: fn(), + onVariantToggle: fn(), + selectedVariantCount: 3, + onSavePress: fn(), + isSaveDisabled: false, + isSaving: false, +}; + +const meta: Meta = { + title: 'Screens/POS/TagAssignmentScreen', + component: TagAssignmentScreen, + parameters: { layout: 'fullscreen' }, + args: defaultArgs, +}; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + args: { + variant: { + type: 'loaded', + categories: [ + { + id: 1, + name: 'Snacks', + products: [ + { + id: 11, + name: 'Pancong', + checkState: 'indeterminate', + variants: [ + { id: 101, name: 'Choco', isChecked: false }, + { id: 102, name: 'Matcha', isChecked: false }, + { id: 103, name: 'Vanilla', isChecked: false }, + { id: 104, name: 'Ice Cream', isChecked: true }, + ], + }, + ], + }, + { + id: 2, + name: 'Drinks', + products: [ + { + id: 12, + name: 'Salted Caramel Macchiato', + checkState: true, + variants: [{ id: 105, name: 'Original', isChecked: true }], + }, + { + id: 13, + name: 'Coffee Latte', + checkState: true, + variants: [ + { id: 106, name: 'Hot', isChecked: true }, + { id: 107, name: 'Iced', isChecked: true }, + ], + }, + ], + }, + ], + }, + }, +}; + +export const Loading: Story = { + args: { variant: { type: 'loading' }, isSaveDisabled: true }, +}; + +export const Empty: Story = { + args: { variant: { type: 'empty' }, searchValue: 'zzz' }, +}; + +export const Error: Story = { + args: { variant: { type: 'error' }, isSaveDisabled: true }, +}; + +export const Saving: Story = { + args: { ...Loaded.args, isSaving: true, isSaveDisabled: true }, +}; + +export const SaveFailed: Story = { + args: { ...Loaded.args, serverError: 'Failed to save. Please try again.' }, +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.tsx b/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.tsx new file mode 100644 index 00000000..b0b89868 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.tsx @@ -0,0 +1,85 @@ +import { Button, Input, Paragraph, Spinner, XStack, YStack } from 'tamagui'; +import { X } from '@tamagui/lucide-icons'; +import { + FormErrorBanner, + Layout, + TagAssignmentList, + TagAssignmentListProps, +} from '../../components'; + +export type TagAssignmentScreenProps = { + onLogoutPress: () => void; + tagName?: string; + searchValue: string; + onSearchValueChange: (value: string) => void; + variant: TagAssignmentListProps['variant']; + onRetryButtonPress: () => void; + onProductToggle: (productId: number) => void; + onVariantToggle: (variantId: number) => void; + selectedVariantCount: number; + onSavePress: () => void; + isSaveDisabled: boolean; + isSaving: boolean; + serverError?: string; +}; + +export const TagAssignmentScreen = ({ + onLogoutPress, + tagName, + searchValue, + onSearchValueChange, + variant, + onRetryButtonPress, + onProductToggle, + onVariantToggle, + selectedVariantCount, + onSavePress, + isSaveDisabled, + isSaving, + serverError, +}: TagAssignmentScreenProps) => { + return ( + + + + + + + + + ); +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.stories.tsx new file mode 100644 index 00000000..5b66cec3 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagCreateScreen } from './TagCreateScreen'; + +const meta: Meta = { + title: 'Screens/POS/TagCreateScreen', + component: TagCreateScreen, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultValues: { + name: '', + color: 'gray', + isHighlighted: false, + sortOrder: 0, + }, + onSubmit: fn(), + isSubmitDisabled: false, + isSubmitting: false, + onLogoutPress: fn(), + variant: { type: 'loaded' }, + }, +}; + +export const Loading: Story = { + args: { + ...Default.args, + isSubmitDisabled: true, + variant: { type: 'loading' }, + }, +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.tsx b/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.tsx new file mode 100644 index 00000000..b849becb --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.tsx @@ -0,0 +1,46 @@ +import { ScrollView } from 'tamagui'; +import { + TagFormView, + TagFormViewProps, + Layout, +} from '../../components'; +import { TagForm } from '../../../../domain'; + +export type TagCreateScreenProps = { + onLogoutPress: () => void; + defaultValues: TagForm; + isSubmitDisabled: boolean; + isSubmitting: boolean; + onSubmit: (values: TagForm) => void; + variant: TagFormViewProps['variant']; + serverError?: string; +}; + +export const TagCreateScreen = ({ + defaultValues, + isSubmitDisabled, + isSubmitting, + onLogoutPress, + onSubmit, + variant, + serverError, +}: TagCreateScreenProps) => { + return ( + + + + + + ); +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx new file mode 100644 index 00000000..6fd22438 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagListScreen } from './TagListScreen'; +import { mockTags } from '../../../../../.storybook/mocks/mockData'; + +const defaultArgs = { + onLogoutPress: fn(), + onEditMenuPress: fn(), + onAssignMenuPress: fn(), + onDeleteMenuPress: fn(), + onItemPress: fn(), + onRetryButtonPress: fn(), + isDeleteButtonDisabled: false, + isDeleteModalOpen: false, + onDeleteCancel: fn(), + onDeleteConfirm: fn(), +}; + +const meta: Meta = { + title: 'Screens/POS/TagListScreen', + component: TagListScreen, + parameters: { layout: 'fullscreen' }, + args: defaultArgs, +}; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + args: { variant: { type: 'loaded', tags: mockTags } }, +}; + +export const Loading: Story = { + args: { variant: { type: 'loading' } }, +}; + +export const Empty: Story = { + args: { variant: { type: 'empty' } }, +}; + +export const Error: Story = { + args: { variant: { type: 'error' } }, +}; + +export const DeleteModalOpen: Story = { + args: { + variant: { type: 'loaded', tags: mockTags }, + isDeleteModalOpen: true, + deleteVariantCount: 5, + }, +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx b/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx new file mode 100644 index 00000000..2d01d2c7 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx @@ -0,0 +1,74 @@ +import { Button } from 'tamagui'; +import { Link } from 'solito/link'; +import { Plus } from '@tamagui/lucide-icons'; +import { + TagDeleteAlert, + TagList, + Layout, + TagListProps, +} from '../../components'; +import { Tag } from '../../../../domain'; + +export type TagListScreenProps = { + onLogoutPress: () => void; + onEditMenuPress: (tag: Tag) => void; + onAssignMenuPress: (tag: Tag) => void; + onDeleteMenuPress: (tag: Tag) => void; + onItemPress: (tag: Tag) => void; + onRetryButtonPress: () => void; + variant: TagListProps['variant']; + isRevalidating?: boolean; + isDeleteButtonDisabled: boolean; + isDeleteModalOpen: boolean; + deleteVariantCount?: number; + onDeleteCancel: () => void; + onDeleteConfirm: () => void; + onEmptyActionPress?: () => void; +}; + +export const TagListScreen = ({ + onLogoutPress, + onEditMenuPress, + onAssignMenuPress, + onDeleteMenuPress, + onItemPress, + onRetryButtonPress, + variant, + isRevalidating, + isDeleteButtonDisabled, + isDeleteModalOpen, + deleteVariantCount, + onDeleteCancel, + onDeleteConfirm, + onEmptyActionPress, +}: TagListScreenProps) => { + return ( + +