feat: Refine trail category model (#1059)

* feat: advanced trail categories

* rename remote_category

* fix merge issues

* subcategories for plugins mapping

* fix refresh

* remove vertical trail filter category scrolling

* cleanup

* add confirm modal for disabling a category

* Fix federation issues

* fix review findings

* redesign category settings page

* remove prio badge

* fix subcategory badge layout

* optimize subcategory settings layout

* further settings page layout optimization

* fix subcategory icon position

* fix

* update docs

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
slothful-vassal
2026-06-29 03:16:26 +02:00
committed by GitHub
parent 50cb6c7f5f
commit 3b8f00fd26
116 changed files with 9363 additions and 453 deletions

View File

@@ -7,6 +7,9 @@ import (
"testing"
"time"
"github.com/pocketbase/pocketbase/core"
pbtests "github.com/pocketbase/pocketbase/tests"
pluginsystem "pocketbase/pluginsystem"
"pocketbase/util"
)
@@ -264,7 +267,7 @@ func TestCategoryIDForImportDoesNotFallbackWhenProviderMappingIsBlank(t *testing
},
}
if got := categoryIDForImport(nil, item, map[string]string{"Ride": ""}); got != "" {
if got := categoryIDForImport(nil, item, map[string]CategoryMappingValue{"Ride": {}}); got != "" {
t.Fatalf("expected blank provider mapping to suppress activity fallback, got %q", got)
}
}
@@ -282,6 +285,121 @@ func TestProviderCategoryFromImport(t *testing.T) {
}
}
func TestCategoryNameForActivityType(t *testing.T) {
cases := map[string]string{
"run": "Running",
"running": "Running",
"VirtualRun": "Running",
"trailrun": "Running",
"jogging": "Running",
"walk": "Walking",
"hike": "Hiking",
"unknown": "",
}
for activityType, want := range cases {
if got := categoryNameForActivityType(activityType); got != want {
t.Fatalf("categoryNameForActivityType(%q) = %q, want %q", activityType, got, want)
}
}
}
func TestCategoryFromProviderMappingUsesNormalizedCategoryName(t *testing.T) {
app := setupImporterCategoryTestApp(t)
category := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
category.Set("name", "Trail Running")
if err := app.Save(category); err != nil {
t.Fatal(err)
}
got, matched := CategoryFromProviderMapping(app, "Run", map[string]CategoryMappingValue{"Run": {Category: "trail-running"}})
if !matched {
t.Fatal("expected provider mapping to match")
}
if got != category.Id {
t.Fatalf("CategoryFromProviderMapping() = %q, want %q", got, category.Id)
}
}
func TestCategoryTargetFromProviderMappingSupportsSubcategoryPath(t *testing.T) {
app := setupImporterCategoryTestApp(t)
category := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
category.Set("name", "Running")
if err := app.Save(category); err != nil {
t.Fatal(err)
}
subcategory := core.NewRecord(mustFindImporterTestCollection(t, app, "subcategories"))
subcategory.Set("category", category.Id)
subcategory.Set("name", "Trail")
if err := app.Save(subcategory); err != nil {
t.Fatal(err)
}
target, matched := CategoryTargetFromProviderMapping(app, "TrailRun", map[string]CategoryMappingValue{"TrailRun": {Category: "Running", Subcategory: "Trail"}})
if !matched {
t.Fatal("expected provider mapping to match")
}
if target.CategoryID != category.Id || target.SubcategoryID != subcategory.Id {
t.Fatalf("CategoryTargetFromProviderMapping() = %#v, want category=%q subcategory=%q", target, category.Id, subcategory.Id)
}
}
func TestCategoryTargetFromProviderMappingPrefersLiteralCategoryWithSlash(t *testing.T) {
app := setupImporterCategoryTestApp(t)
slashCategory := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
slashCategory.Set("name", "Foo/Bar")
if err := app.Save(slashCategory); err != nil {
t.Fatal(err)
}
parentCategory := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
parentCategory.Set("name", "Foo")
if err := app.Save(parentCategory); err != nil {
t.Fatal(err)
}
subcategory := core.NewRecord(mustFindImporterTestCollection(t, app, "subcategories"))
subcategory.Set("category", parentCategory.Id)
subcategory.Set("name", "Bar")
if err := app.Save(subcategory); err != nil {
t.Fatal(err)
}
target, matched := CategoryTargetFromProviderMapping(app, "Provider", map[string]CategoryMappingValue{"Provider": {Category: "Foo/Bar"}})
if !matched {
t.Fatal("expected provider mapping to match")
}
if target.CategoryID != slashCategory.Id || target.SubcategoryID != "" {
t.Fatalf("CategoryTargetFromProviderMapping() = %#v, want literal category %q", target, slashCategory.Id)
}
}
func TestCategoryTargetFromProviderMappingDoesNotSplitSlashCategoryName(t *testing.T) {
app := setupImporterCategoryTestApp(t)
category := core.NewRecord(mustFindImporterTestCollection(t, app, "categories"))
category.Set("name", "Foo")
if err := app.Save(category); err != nil {
t.Fatal(err)
}
subcategory := core.NewRecord(mustFindImporterTestCollection(t, app, "subcategories"))
subcategory.Set("category", category.Id)
subcategory.Set("name", "Bar")
if err := app.Save(subcategory); err != nil {
t.Fatal(err)
}
target, matched := CategoryTargetFromProviderMapping(app, "Provider", map[string]CategoryMappingValue{"Provider": {Category: "Foo/Bar"}})
if matched {
t.Fatalf("CategoryTargetFromProviderMapping() = %#v, expected slash category name not to be split", target)
}
}
func TestDateFromImport(t *testing.T) {
started := time.Date(2025, 6, 1, 8, 0, 0, 0, time.UTC)
@@ -430,3 +548,42 @@ func TestRemoveRawQueryParamOrdered(t *testing.T) {
t.Fatalf("unexpected query: %q", got)
}
}
func setupImporterCategoryTestApp(t *testing.T) *pbtests.TestApp {
t.Helper()
app, err := pbtests.NewTestApp(t.TempDir())
if err != nil {
t.Fatal(err)
}
categories := core.NewBaseCollection("categories")
categories.Fields.Add(&core.TextField{Name: "name", Required: true})
if err := app.Save(categories); err != nil {
app.Cleanup()
t.Fatal(err)
}
subcategories := core.NewBaseCollection("subcategories")
subcategories.Fields.Add(
&core.RelationField{Name: "category", CollectionId: categories.Id, MaxSelect: 1, Required: true},
&core.TextField{Name: "name", Required: true},
)
if err := app.Save(subcategories); err != nil {
app.Cleanup()
t.Fatal(err)
}
return app
}
func mustFindImporterTestCollection(t *testing.T, app core.App, name string) *core.Collection {
t.Helper()
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
t.Fatal(err)
}
return collection
}