From 9d6061828b08185b963d16d45d435a1ef7ac9995 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 19:18:14 +0800 Subject: [PATCH 1/4] feat(categories): nest product and catalog categories up to three levels (server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product categories and catalog (menu) categories nest: Drinks › Coffee › Cold brew. A parent is in the same store or catalog and of the same kind, and a tree is at most three levels deep; CategoryTreeObserver refuses anything else with a 422 and moves a removed category's subcategories up a level. - Public categories carry parent (public id) and depth; with_subcategories lists the categories under each one; with_products&rollup=1 brings every product under a category. - Products filtered by a category (public category=, console category and category_slug) include its subcategories' products; category_direct=1 keeps an exact match. Public products carry their category's public id. - A catalog saves its categories as a tree (parent_uuid, or parent_key for a category created in the same save), validated whole and attached top-down; the public catalog payload gives each category's parent. --- .../Controllers/v1/CategoryController.php | 169 +++++---- .../Http/Controllers/v1/ProductController.php | 8 +- server/src/Http/Filter/ProductFilter.php | 6 +- server/src/Http/Resources/Catalog.php | 13 +- server/src/Http/Resources/CatalogCategory.php | 2 + server/src/Http/Resources/Category.php | 20 +- server/src/Http/Resources/Product.php | 3 +- server/src/Models/Catalog.php | 28 ++ server/src/Models/Product.php | 3 +- server/src/Observers/CategoryTreeObserver.php | 42 +++ .../Providers/StorefrontServiceProvider.php | 3 + server/src/Support/CategoryTree.php | 214 +++++++++++ .../ProductApiControllerContractsTest.php | 38 +- .../Filter/StorefrontFilterContractsTest.php | 17 +- .../Filter/StorefrontFilterCoverageTest.php | 3 +- .../Http/Resources/ProductResourceTest.php | 2 + .../Unit/Models/ApiCacheDependenciesTest.php | 2 +- .../Unit/Support/NestedCategoriesTest.php | 350 ++++++++++++++++++ 18 files changed, 808 insertions(+), 115 deletions(-) create mode 100644 server/src/Observers/CategoryTreeObserver.php create mode 100644 server/src/Support/CategoryTree.php create mode 100644 server/tests/Unit/Support/NestedCategoriesTest.php diff --git a/server/src/Http/Controllers/v1/CategoryController.php b/server/src/Http/Controllers/v1/CategoryController.php index 63d3aa8a..8ed0f503 100644 --- a/server/src/Http/Controllers/v1/CategoryController.php +++ b/server/src/Http/Controllers/v1/CategoryController.php @@ -6,9 +6,10 @@ use Fleetbase\Models\Category; use Fleetbase\Storefront\Http\Resources\Category as StorefrontCategory; use Fleetbase\Storefront\Http\Resources\Product as ProductResource; -use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\NetworkStore; +use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\Store; +use Fleetbase\Storefront\Support\CategoryTree; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -24,43 +25,7 @@ public function query(Request $request) $results = []; if (session('storefront_store')) { - $results = Category::queryWithRequestCached($request, function (&$query) use ($request) { - $query->select([DB::raw('public_id as id'), 'public_id', 'uuid', 'parent_uuid', 'icon_file_uuid', 'name', 'description', 'tags', 'translations', 'icon', 'slug', 'created_at'])->where(['owner_uuid' => session('storefront_store'), 'for' => 'storefront_product']); - - // only parent categories - if ($request->has('parents_only')) { - $query->whereNull('parent_uuid'); - } - - // get categories with parent - if ($request->has('parent')) { - $query->whereHas('parentCategory', function ($q) use ($request) { - $q->where('public_id', $request->input('parent')); - }); - } - }); - - // if we want to get categories with products - if ($request->has('with_products')) { - // get all category ids - $categoryIds = $results->map(function ($category) { - return $category->uuid; - })->toArray(); - - // get all products in these categories - $products = Product::whereIn('category_uuid', $categoryIds) - ->where('store_uuid', session('storefront_store')) - ->where('is_available', 1) - ->where('status', 'published') - ->with(['addonCategories', 'variants', 'files']) - ->get(); - - $results = $results->map(function ($category) use ($products) { - $category->products = $products->where('category_uuid', $category->uuid)->mapInto(ProductResource::class)->values(); - - return $category; - }); - } + $results = $this->productCategories($request, session('storefront_store')); } if (session('storefront_network')) { @@ -70,44 +35,7 @@ public function query(Request $request) ])->activeInNetwork(session('storefront_network'))->first(); if ($store) { - $results = Category::queryWithRequestCached($request, function (&$query) use ($store, $request) { - $query->select([DB::raw('public_id as id'), 'public_id', 'uuid', 'parent_uuid', 'icon_file_uuid', 'name', 'icon', 'description', 'tags', 'translations', 'slug', 'created_at'])->where(['owner_uuid' => $store->uuid, 'for' => 'storefront_product']); - - // only parent categories - if ($request->has('parents_only')) { - $query->whereNull('parent_uuid'); - } - - // get categories with parent - if ($request->has('parent')) { - $query->whereHas('parentCategory', function ($q) use ($request) { - $q->where('public_id', $request->input('parent')); - }); - } - }); - - // if we want to get categories with products - if ($request->has('with_products')) { - // get all category ids - $categoryIds = $results->map(function ($category) { - return $category->uuid; - })->toArray(); - - // get all products in these categories - $products = Product::whereIn('category_uuid', $categoryIds) - ->where('store_uuid', $store->uuid) - ->where('is_available', 1) - ->where('status', 'published') - ->with(['addonCategories', 'variants', 'files']) - ->get(); - - $results = $results->map(function ($category) use ($products) { - // $category->products = Product::where('category_uuid', $category->uuid)->get()->mapInto(ProductResource::class); - $category->products = $products->where('category_uuid', $category->uuid)->mapInto(ProductResource::class)->values(); - - return $category; - }); - } + $results = $this->productCategories($request, $store->uuid); } } else { $results = Category::queryWithRequestCached($request, function (&$query) use ($request) { @@ -126,6 +54,8 @@ public function query(Request $request) } }); + $results = $this->withTreeIndex($results, Category::where(['owner_uuid' => session('storefront_network'), 'for' => 'storefront_network'])->get(['uuid', 'public_id', 'parent_uuid'])); + // if we want to get categories with stores if ($request->has('with_stores')) { $categoryIds = $results->pluck('uuid'); @@ -153,4 +83,91 @@ public function query(Request $request) return StorefrontCategory::collection($results); } + + /** + * A store's product categories. Each carries its parent's public id and its level, so an app + * can build the Drinks › Coffee › Cold brew tree; `with_subcategories` adds the categories + * directly under each one; `with_products` adds the products, and with `rollup` also those of + * every category under it. + */ + protected function productCategories(Request $request, string $storeUuid) + { + $columns = [DB::raw('public_id as id'), 'public_id', 'uuid', 'parent_uuid', 'icon_file_uuid', 'name', 'description', 'tags', 'translations', 'icon', 'slug', 'order', 'created_at']; + $results = Category::queryWithRequestCached($request, function (&$query) use ($request, $storeUuid, $columns) { + $query->select($columns)->where(['owner_uuid' => $storeUuid, 'for' => 'storefront_product']); + + // only parent categories + if ($request->has('parents_only')) { + $query->whereNull('parent_uuid'); + } + + // get categories with parent + if ($request->has('parent')) { + $query->whereHas('parentCategory', function ($q) use ($request) { + $q->where('public_id', $request->input('parent')); + }); + } + }); + + // Every category of the store, once: the index, the subcategories and the rollup read it. + $all = Category::select($columns)->where(['owner_uuid' => $storeUuid, 'for' => 'storefront_product'])->orderBy('order')->get(); + $results = $this->withTreeIndex($results, $all); + + if ($request->has('with_subcategories') || $request->inArray('with', 'subcategories')) { + $children = $this->withTreeIndex($all, $all)->groupBy('parent_uuid'); + $results = $results->map(function ($category) use ($children) { + $category->setRelation('subCategories', $children->get($category->uuid, collect())->values()); + + return $category; + }); + } + + if ($request->has('with_products')) { + $rollup = $request->boolean('rollup'); + $under = function (string $uuid) use ($all, $rollup): array { + if (!$rollup) { + return [$uuid]; + } + + $uuids = [$uuid]; + for ($level = [$uuid], $depth = 1; $level && $depth < CategoryTree::MAX_DEPTH; $depth++) { + $level = $all->whereIn('parent_uuid', $level)->pluck('uuid')->all(); + $uuids = array_merge($uuids, $level); + } + + return $uuids; + }; + + $categoryIds = $results->flatMap(fn ($category) => $under($category->uuid))->unique()->values()->all(); + $products = Product::whereIn('category_uuid', $categoryIds) + ->where('store_uuid', $storeUuid) + ->where('is_available', 1) + ->where('status', 'published') + ->with(['addonCategories', 'variants', 'files', 'category:uuid,public_id']) + ->get(); + + $results = $results->map(function ($category) use ($products, $under) { + $category->products = $products->whereIn('category_uuid', $under($category->uuid))->mapInto(ProductResource::class)->values(); + + return $category; + }); + } + + return $results; + } + + /** + * Set each category's parent public id and level from the categories of the same store or network. + */ + protected function withTreeIndex($results, $all) + { + $index = CategoryTree::index($all); + + return collect($results)->map(function ($category) use ($index) { + $category->setAttribute('parent_public_id', data_get($index, $category->uuid . '.parent')); + $category->setAttribute('depth', data_get($index, $category->uuid . '.depth', 1)); + + return $category; + }); + } } diff --git a/server/src/Http/Controllers/v1/ProductController.php b/server/src/Http/Controllers/v1/ProductController.php index e87bf554..764ec8e9 100644 --- a/server/src/Http/Controllers/v1/ProductController.php +++ b/server/src/Http/Controllers/v1/ProductController.php @@ -17,6 +17,7 @@ use Fleetbase\Storefront\Models\ProductVariantOption; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Support\BookingSlots; +use Fleetbase\Storefront\Support\CategoryTree; use Fleetbase\Support\Utils; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -361,7 +362,7 @@ public function query(Request $request) $query->where('is_available', 1); $query->where('status', 'published'); - $relations = ['addonCategories.category', 'variants.options', 'files']; + $relations = ['addonCategories.category', 'variants.options', 'files', 'category:uuid,public_id']; if ($request->boolean('with_store') || $request->inArray('with', 'store')) { $relations[] = 'store.logo'; $relations[] = 'store.backdrop'; @@ -371,8 +372,9 @@ public function query(Request $request) if ($request->filled('category')) { $category = Category::where(['public_id' => $request->input('category'), 'for' => 'storefront_product'])->first(); + // A category's products and those of every category under it, unless asked for its own only. if ($category) { - $query->where('category_uuid', $category->uuid); + $query->whereIn('category_uuid', $request->boolean('category_direct') ? [$category->uuid] : CategoryTree::withDescendants($category)); } } }); @@ -396,7 +398,7 @@ public function find($id) $query->where('is_available', 1); $query->where('status', 'published'); }) - ->with(['addonCategories.category', 'variants.options', 'files']) + ->with(['addonCategories.category', 'variants.options', 'files', 'category:uuid,public_id']) ->first(); if (!$product) { diff --git a/server/src/Http/Filter/ProductFilter.php b/server/src/Http/Filter/ProductFilter.php index ae6cee73..e7bac406 100644 --- a/server/src/Http/Filter/ProductFilter.php +++ b/server/src/Http/Filter/ProductFilter.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Filter\Filter; use Fleetbase\Models\Category; use Fleetbase\Storefront\Models\Product; +use Fleetbase\Storefront\Support\CategoryTree; use Fleetbase\Support\Http; class ProductFilter extends Filter @@ -24,8 +25,9 @@ public function categorySlug(?string $categorySlug) { $category = Category::where(['slug' => $categorySlug, 'for' => 'storefront_product'])->first(); + // A category lists its own products and those of every category under it. if ($category) { - $this->builder->where('category_uuid', $category->uuid); + $this->builder->whereIn('category_uuid', CategoryTree::withDescendants($category)); } } @@ -46,7 +48,7 @@ public function category(?string $category) return; } - $this->builder->where('category_uuid', $category); + $this->builder->whereIn('category_uuid', CategoryTree::withDescendants($category)); } /** diff --git a/server/src/Http/Resources/Catalog.php b/server/src/Http/Resources/Catalog.php index 000d020b..31646ee6 100644 --- a/server/src/Http/Resources/Catalog.php +++ b/server/src/Http/Resources/Catalog.php @@ -25,7 +25,7 @@ public function toArray($request) 'store_uuid' => $this->when(Http::isInternalRequest(), $this->store_uuid), 'name' => $this->name, 'description' => $this->description, - 'categories' => CatalogCategory::collection($this->categories ?? []), + 'categories' => CatalogCategory::collection($this->categoriesWithParents()), 'hours' => $this->when(Http::isInternalRequest(), $this->resource->relationLoaded('hours') ? $this->hours->map(fn ($hour) => ['id' => $hour->uuid, 'uuid' => $hour->uuid, 'catalog_uuid' => $hour->catalog_uuid, 'day_of_week' => $hour->day_of_week, 'start' => $hour->start, 'end' => $hour->end])->values() : []), 'subjects' => $this->when(Http::isInternalRequest(), $this->subjectsSummary()), 'status' => $this->status, @@ -34,6 +34,17 @@ public function toArray($request) ]; } + /** + * The catalog's categories, each told its parent's public id for the public menu tree. + */ + private function categoriesWithParents() + { + $categories = collect($this->categories ?? []); + $publicIds = $categories->pluck('public_id', 'uuid'); + + return $categories->each(fn ($category) => $category->setAttribute('parent_public_id', $category->parent_uuid ? $publicIds->get($category->parent_uuid) : null)); + } + /** * The stores and trucks this catalog is served by, in the shape the console lists them. */ diff --git a/server/src/Http/Resources/CatalogCategory.php b/server/src/Http/Resources/CatalogCategory.php index e51a8430..447785d1 100644 --- a/server/src/Http/Resources/CatalogCategory.php +++ b/server/src/Http/Resources/CatalogCategory.php @@ -23,6 +23,8 @@ public function toArray($request) 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), 'parent_uuid' => $this->when(Http::isInternalRequest(), $this->parent_uuid), + // Publicly the parent's public id, set by the catalog: an app builds the menu's tree from it. + 'parent' => $this->when(!Http::isInternalRequest() && array_key_exists('parent_public_id', $this->resource->getAttributes()), fn () => $this->resource->getAttributes()['parent_public_id']), 'store_uuid' => $this->when(Http::isInternalRequest(), $this->store_uuid), 'owner_uuid' => $this->when(Http::isInternalRequest(), $this->owner_uuid), 'name' => $this->name, diff --git a/server/src/Http/Resources/Category.php b/server/src/Http/Resources/Category.php index 7eb62e23..d9045d6f 100644 --- a/server/src/Http/Resources/Category.php +++ b/server/src/Http/Resources/Category.php @@ -17,29 +17,27 @@ class Category extends FleetbaseResource */ public function toArray($request) { + $attributes = $this->resource->getAttributes(); + return [ 'id' => $this->public_id, 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), 'name' => $this->name, 'description' => $this->description, 'icon_url' => $this->icon_url, - 'parent' => $this->whenLoaded( - 'parentCategory', - function ($parentCategory) { - return $parentCategory->public_id; - } + // The parent's public id and the level (1 at the top), from the store's category index; + // an app builds the Drinks › Coffee › Cold brew tree from these. + 'parent' => $this->when( + array_key_exists('parent_public_id', $attributes) || $this->resource->relationLoaded('parentCategory'), + fn () => array_key_exists('parent_public_id', $attributes) ? $attributes['parent_public_id'] : $this->parentCategory?->public_id ), + 'depth' => $this->when(array_key_exists('depth', $attributes), fn () => (int) $attributes['depth']), 'tags' => Utils::arrayFrom($this->tags), 'translations' => $this->translations ?? [], 'products' => $this->when($request->has('with_products') || $request->inArray('with', 'products'), $this->products ? Product::collection($this->products) : []), 'subcategories' => $this->when( $request->has('with_subcategories') || $request->inArray('with', 'subcategories'), - array_map( - function ($subCategory) { - return new Category($subCategory); - }, - $this->subCategories->toArray() - ) + fn () => Category::collection($this->resource->relationLoaded('subCategories') ? $this->subCategories : collect()) ), 'meta' => data_get($this, 'meta', Utils::createObject()), 'order' => $this->order, diff --git a/server/src/Http/Resources/Product.php b/server/src/Http/Resources/Product.php index 9be390de..fdfb2da8 100644 --- a/server/src/Http/Resources/Product.php +++ b/server/src/Http/Resources/Product.php @@ -32,7 +32,8 @@ public function toArray($request) 'primary_image_uuid' => $this->when(Http::isInternalRequest(), $this->primary_image_uuid), 'name' => $this->name, 'description' => $this->description, - 'category' => $this->when(Http::isInternalRequest(), $this->category), + // Publicly the category's public id, so an app can group a parent's products by subcategory. + 'category' => Http::isInternalRequest() ? $this->category : $this->whenLoaded('category', fn ($category) => $category?->public_id), 'store' => $this->when($request->boolean('with_store') || $request->inArray('with', 'store'), new Store($this->store)), 'sku' => $this->sku, 'primary_image_url' => $this->primary_image_url, diff --git a/server/src/Models/Catalog.php b/server/src/Models/Catalog.php index 85422091..a4e3971d 100644 --- a/server/src/Models/Catalog.php +++ b/server/src/Models/Catalog.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Models\Company; use Fleetbase\Models\User; +use Fleetbase\Storefront\Support\CategoryTree; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasPublicid; @@ -191,6 +192,18 @@ public function setCategories(array $categories = []): Catalog $cat->delete(); // or $cat->forceDelete() if you don't want soft deletes }); + // Each category's parent: an existing one by `parent_uuid`, or one created in this same save + // by its `parent_key` (the client's `key` for a category that has no uuid yet). + $keys = []; + $parents = []; + foreach ($categories as $index => $categoryData) { + $key = (string) (data_get($categoryData, 'uuid') ?: data_get($categoryData, 'key') ?: 'index:' . $index); + $keys[$index] = $key; + $parents[$key] = data_get($categoryData, 'parent_uuid') ?: data_get($categoryData, 'parent_key') ?: null; + } + CategoryTree::assertValidTree($parents); + $records = []; + // 2) Loop through incoming categories array foreach ($categories as $index => $categoryData) { $categoryUuid = data_get($categoryData, 'uuid'); @@ -221,6 +234,21 @@ public function setCategories(array $categories = []): Catalog // 3) Update products for this category $categoryRecord->setProducts($products, $overrides); + $records[$keys[$index]] = $categoryRecord; + } + + // 4) Place the categories: detach the moved ones, then attach parents before their children, + // so no save along the way runs past three levels or loops. + $parentUuids = array_map(fn ($parentKey) => $parentKey === null ? null : data_get($records, $parentKey . '.uuid'), $parents); + foreach ($records as $key => $record) { + if ($record->parent_uuid !== null && $record->parent_uuid !== $parentUuids[$key]) { + $record->update(['parent_uuid' => null]); + } + } + foreach (CategoryTree::topDown($parents) as $key) { + if ($records[$key]->parent_uuid !== $parentUuids[$key]) { + $records[$key]->update(['parent_uuid' => $parentUuids[$key]]); + } } return $this; diff --git a/server/src/Models/Product.php b/server/src/Models/Product.php index 110362f8..86b4b857 100644 --- a/server/src/Models/Product.php +++ b/server/src/Models/Product.php @@ -66,7 +66,8 @@ class Product extends StorefrontModel * * @var string[] */ - protected $apiCacheDependencies = ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'store', 'store.networks', 'catalogCategories']; + // `category`: moving a category under another changes which products a parent rolls up. + protected $apiCacheDependencies = ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'store', 'store.networks', 'catalogCategories', 'category']; /** * The default database connection to use. diff --git a/server/src/Observers/CategoryTreeObserver.php b/server/src/Observers/CategoryTreeObserver.php new file mode 100644 index 00000000..24e61262 --- /dev/null +++ b/server/src/Observers/CategoryTreeObserver.php @@ -0,0 +1,42 @@ +isDirty('parent_uuid') || !$category->parent_uuid) { + return; + } + + $parent = Category::where('uuid', $category->parent_uuid)->first(); + if (!$parent) { + CategoryTree::fail('The parent category no longer exists.'); + } + + CategoryTree::assertValidParent($category, $parent); + } + + public function deleting(Category $category): void + { + if (!CategoryTree::nests($category)) { + return; + } + + $moved = Category::where('parent_uuid', $category->uuid)->update(['parent_uuid' => $category->parent_uuid]); + + // A builder update fires no model events, so the cached category lists are told here. + if ($moved > 0) { + ApiModelCache::invalidateModelCache(new Category(), $category->company_uuid); + } + } +} diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 380da192..4fbf9c4b 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -38,6 +38,9 @@ class StorefrontServiceProvider extends CoreServiceProvider \Fleetbase\Models\ChatMessage::class => \Fleetbase\Storefront\Observers\ChatMessageObserver::class, \Fleetbase\Models\File::class => \Fleetbase\Storefront\Observers\FileObserver::class, \Fleetbase\FleetOps\Models\Order::class => \Fleetbase\Storefront\Observers\OrderActivityObserver::class, + // Product categories are core categories; catalog categories fire their own model events. + \Fleetbase\Models\Category::class => \Fleetbase\Storefront\Observers\CategoryTreeObserver::class, + \Fleetbase\Storefront\Models\CatalogCategory::class => \Fleetbase\Storefront\Observers\CategoryTreeObserver::class, ]; /** diff --git a/server/src/Support/CategoryTree.php b/server/src/Support/CategoryTree.php new file mode 100644 index 00000000..80be21f0 --- /dev/null +++ b/server/src/Support/CategoryTree.php @@ -0,0 +1,214 @@ +for, self::NESTED, true); + } + + /** + * The category's uuid and every uuid under it, walking down a level at a time. + * + * @return array + */ + public static function withDescendants(Category|string $category): array + { + $uuid = $category instanceof Category ? $category->uuid : $category; + $all = [$uuid]; + $level = [$uuid]; + + // A level-one category has at most two levels under it. + for ($depth = 1; $depth < self::MAX_DEPTH && $level; $depth++) { + $level = Category::whereIn('parent_uuid', $level)->pluck('uuid')->all(); + $level = array_values(array_diff($level, $all)); + $all = array_merge($all, $level); + } + + return $all; + } + + /** How many levels sit under the category: 0 for a category without subcategories. */ + public static function height(Category $category): int + { + $height = 0; + $level = [$category->uuid]; + $seen = [$category->uuid]; + + while ($level && $height <= self::MAX_DEPTH) { + $level = array_values(array_diff(Category::whereIn('parent_uuid', $level)->pluck('uuid')->all(), $seen)); + $seen = array_merge($seen, $level); + if ($level) { + $height++; + } + } + + return $height; + } + + /** + * The categories above this one, nearest first. + * + * @return array + */ + public static function ancestors(Category $category): array + { + $ancestors = []; + $seen = [$category->uuid]; + $parentId = $category->parent_uuid; + + while ($parentId && !in_array($parentId, $seen, true) && count($ancestors) <= self::MAX_DEPTH) { + $parent = Category::where('uuid', $parentId)->first(); + if (!$parent) { + break; + } + $ancestors[] = $parent; + $seen[] = $parent->uuid; + $parentId = $parent->parent_uuid; + } + + return $ancestors; + } + + /** The category's level: 1 at the top. */ + public static function depth(Category $category): int + { + return count(static::ancestors($category)) + 1; + } + + /** + * Refuse a parent that would put the category under itself, in another store or catalog, + * or more than three levels deep (counting the levels already under the category). + * + * @throws ValidationException + */ + public static function assertValidParent(Category $category, ?Category $parent): void + { + if (!$parent) { + return; + } + + if ($parent->owner_uuid !== $category->owner_uuid || $parent->for !== $category->for) { + static::fail('A category can only be placed under a category of the same store or menu.'); + } + + if ($category->exists && in_array($parent->uuid, static::withDescendants($category), true)) { + static::fail('A category cannot be placed under itself or one of its own subcategories.'); + } + + $height = $category->exists ? static::height($category) : 0; + if (static::depth($parent) + 1 + $height > self::MAX_DEPTH) { + static::fail('Categories nest at most three levels deep.'); + } + } + + /** + * Check a whole tree before it is written: `$parents` maps each key to its parent's key (or + * null). Every parent must be in the tree, and no branch may loop or run past three levels. + * + * @param array $parents + * + * @throws ValidationException + */ + public static function assertValidTree(array $parents): void + { + foreach (array_keys($parents) as $key) { + $depth = 1; + $seen = [$key]; + $at = $parents[$key]; + + while ($at !== null) { + if (!array_key_exists($at, $parents)) { + static::fail('A category was placed under a category that is not in this menu.'); + } + if (in_array($at, $seen, true)) { + static::fail('A category cannot be placed under itself or one of its own subcategories.'); + } + $seen[] = $at; + $at = $parents[$at]; + $depth++; + } + + if ($depth > self::MAX_DEPTH) { + static::fail('Categories nest at most three levels deep.'); + } + } + } + + /** + * Keys ordered so that every parent comes before its children. + * + * @param array $parents a tree assertValidTree() accepted + * + * @return array + */ + public static function topDown(array $parents): array + { + $depthOf = function (string $key) use ($parents): int { + $depth = 1; + for ($at = $parents[$key]; $at !== null; $at = $parents[$at]) { + $depth++; + } + + return $depth; + }; + + $keys = array_keys($parents); + usort($keys, fn ($a, $b) => $depthOf($a) <=> $depthOf($b)); + + return $keys; + } + + /** + * A category index for one store or catalog: each category's parent's public id and its depth, + * from the flat list, without a query per category. + * + * @return array by uuid + */ + public static function index(Collection $categories): array + { + $byUuid = $categories->keyBy('uuid'); + $index = []; + + foreach ($byUuid as $uuid => $category) { + $depth = 1; + $seen = [$uuid]; + $parentId = $category->parent_uuid; + + while ($parentId && $byUuid->has($parentId) && !in_array($parentId, $seen, true)) { + $seen[] = $parentId; + $parentId = $byUuid->get($parentId)->parent_uuid; + $depth++; + } + + $index[$uuid] = [ + 'parent' => $category->parent_uuid ? data_get($byUuid->get($category->parent_uuid), 'public_id') : null, + 'depth' => $depth, + ]; + } + + return $index; + } + + /** @throws ValidationException */ + public static function fail(string $message): void + { + throw ValidationException::withMessages(['parent' => [$message]]); + } +} diff --git a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php index 46ccec66..dc46f869 100644 --- a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php @@ -147,6 +147,7 @@ function createProductApiControllerSchema(): void $table->string('company_uuid')->nullable(); $table->string('owner_uuid')->nullable(); $table->string('owner_type')->nullable(); + $table->string('parent_uuid')->nullable(); $table->string('name')->nullable(); $table->string('slug')->nullable(); $table->text('description')->nullable(); @@ -557,15 +558,14 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar ->and($draft->getStatusCode())->toBe(404); }); -test('product query applies a known storefront category filter', function () { +test('product query applies a known storefront category filter, with its subcategories unless asked for its own', function () { createProductApiControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); $connection->table('categories')->insert([ - 'uuid' => 'category_uuid', - 'public_id' => 'category_drinks', - 'company_uuid' => 'company_uuid', - 'owner_uuid' => 'store_uuid', - 'for' => 'storefront_product', + ['uuid' => 'category_uuid', 'public_id' => 'category_drinks', 'company_uuid' => 'company_uuid', 'owner_uuid' => 'store_uuid', 'parent_uuid' => null, 'for' => 'storefront_product'], + // Drinks › Coffee › Cold brew + ['uuid' => 'coffee_uuid', 'public_id' => 'category_coffee', 'company_uuid' => 'company_uuid', 'owner_uuid' => 'store_uuid', 'parent_uuid' => 'category_uuid', 'for' => 'storefront_product'], + ['uuid' => 'cold_brew_uuid', 'public_id' => 'category_cold_brew', 'company_uuid' => 'company_uuid', 'owner_uuid' => 'store_uuid', 'parent_uuid' => 'coffee_uuid', 'for' => 'storefront_product'], ]); $connection->table('products')->insert([ [ @@ -578,6 +578,16 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'is_available' => true, 'status' => 'published', ], + [ + 'uuid' => 'cold_brew_product_uuid', + 'public_id' => 'product_cold_brew', + 'company_uuid' => 'company_uuid', + 'store_uuid' => 'store_uuid', + 'category_uuid' => 'cold_brew_uuid', + 'name' => 'Cold brew', + 'is_available' => true, + 'status' => 'published', + ], [ 'uuid' => 'food_uuid', 'public_id' => 'product_food', @@ -600,9 +610,17 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'GET', ['category' => 'category_drinks'] )); + $direct = (new ProductController())->query(productApiRequest( + '/products', + 'GET', + ['category' => 'category_drinks', 'category_direct' => 1] + )); + $coldBrew = $resource->resource->firstWhere('uuid', 'cold_brew_product_uuid'); - expect($resource->resource)->toHaveCount(1) - ->and($resource->resource->first()->uuid)->toBe('drink_uuid'); + expect($resource->resource->pluck('uuid')->sort()->values()->all())->toBe(['cold_brew_product_uuid', 'drink_uuid']) + // An app groups a parent's products by subcategory from each product's category. + ->and($coldBrew->resource->category->public_id)->toBe('category_cold_brew') + ->and($direct->resource->pluck('uuid')->all())->toBe(['drink_uuid']); }); test('product category filter takes a uuid on the console and leaves the public id to the public API', function () { @@ -627,9 +645,9 @@ public function uri(): string $internal = $filtered('int/v1/storefront/products'); $public = $filtered('v1/storefront/products'); - expect($internal->toSql())->toContain('"category_uuid" = ?') + expect($internal->toSql())->toContain('"category_uuid" in (?)') ->and($internal->getBindings())->toContain('category_drinks') - ->and($public->toSql())->not->toContain('"category_uuid" = ?') + ->and($public->toSql())->not->toContain('"category_uuid" in') ->and($public->getBindings())->not->toContain('category_drinks'); }); diff --git a/server/tests/Unit/Http/Filter/StorefrontFilterContractsTest.php b/server/tests/Unit/Http/Filter/StorefrontFilterContractsTest.php index a3bca26b..082d9d8a 100644 --- a/server/tests/Unit/Http/Filter/StorefrontFilterContractsTest.php +++ b/server/tests/Unit/Http/Filter/StorefrontFilterContractsTest.php @@ -110,14 +110,15 @@ function applyStorefrontFilter(string $filter, Builder $builder, string $uri, ar $schema->create('categories', function ($table) { $table->increments('id'); $table->string('uuid'); + $table->string('parent_uuid')->nullable(); $table->string('slug'); $table->string('for'); $table->timestamp('deleted_at')->nullable(); }); $connection->table('categories')->insert([ - 'uuid' => 'category_uuid', - 'slug' => 'coffee', - 'for' => 'storefront_product', + ['uuid' => 'category_uuid', 'parent_uuid' => null, 'slug' => 'coffee', 'for' => 'storefront_product'], + // A subcategory's products are listed under its parent. + ['uuid' => 'cold_brew_uuid', 'parent_uuid' => 'category_uuid', 'slug' => 'cold-brew', 'for' => 'storefront_product'], ]); $known = applyStorefrontFilter( @@ -133,9 +134,9 @@ function applyStorefrontFilter(string $filter, Builder $builder, string $uri, ar ['category_slug' => 'unknown'] ); - expect($known->toSql())->toContain('"category_uuid" = ?') - ->and($known->getBindings())->toContain('category_uuid') - ->and($unknown->toSql())->not->toContain('"category_uuid" = ?'); + expect($known->toSql())->toContain('"category_uuid" in (?, ?)') + ->and($known->getBindings())->toBe(['category_uuid', 'cold_brew_uuid']) + ->and($unknown->toSql())->not->toContain('"category_uuid" in'); }); test('customer filter constrains customers through storefront order metadata', function () { @@ -278,12 +279,12 @@ function applyStorefrontFilter(string $filter, Builder $builder, string $uri, ar }); test('the order fulfillment filter narrows to pickup or delivery orders and ignores anything else', function () { - $pickup = applyStorefrontFilter(OrderFilter::class, (new Order())->newQuery(), 'int/v1/storefront/orders', ['fulfillment' => 'pickup']); + $pickup = applyStorefrontFilter(OrderFilter::class, (new Order())->newQuery(), 'int/v1/storefront/orders', ['fulfillment' => 'pickup']); $pickupSql = $pickup->toSql(); expect($pickupSql)->toContain('is_pickup') ->and(str_contains($pickupSql, 'true') || in_array(true, $pickup->getBindings(), true))->toBeTrue(); - $delivery = applyStorefrontFilter(OrderFilter::class, (new Order())->newQuery(), 'int/v1/storefront/orders', ['fulfillment' => 'Delivery']); + $delivery = applyStorefrontFilter(OrderFilter::class, (new Order())->newQuery(), 'int/v1/storefront/orders', ['fulfillment' => 'Delivery']); $deliverySql = $delivery->toSql(); expect($deliverySql)->toContain('is_pickup') ->and($deliverySql)->toContain('is null') diff --git a/server/tests/Unit/Http/Filter/StorefrontFilterCoverageTest.php b/server/tests/Unit/Http/Filter/StorefrontFilterCoverageTest.php index 7aeb9b72..0ff580c8 100644 --- a/server/tests/Unit/Http/Filter/StorefrontFilterCoverageTest.php +++ b/server/tests/Unit/Http/Filter/StorefrontFilterCoverageTest.php @@ -133,7 +133,8 @@ function filterCoverageProductSchema(): void $internal = (new ProductFilter(filterCoverageRequest('int/v1/storefront/products', ['category' => 'category_food'])))->apply((new Product())->newQuery()); $public = (new ProductFilter(filterCoverageRequest('v1/storefront/products', ['category' => 'category_public_id'])))->apply((new Product())->newQuery()); - expect($internal->toSql())->toContain('"category_uuid" = ?') + // A category takes in its subcategories' products too. + expect($internal->toSql())->toContain('"category_uuid" in (?)') ->and($internal->getBindings())->toContain('category_food') ->and($public->toSql())->not->toContain('category_uuid') ->and($public->getBindings())->not->toContain('category_public_id'); diff --git a/server/tests/Unit/Http/Resources/ProductResourceTest.php b/server/tests/Unit/Http/Resources/ProductResourceTest.php index b05a8d5c..6b7619af 100644 --- a/server/tests/Unit/Http/Resources/ProductResourceTest.php +++ b/server/tests/Unit/Http/Resources/ProductResourceTest.php @@ -151,6 +151,8 @@ function storefrontProductResourceFixture(): ProductModel 'is_on_sale' => true, 'is_available' => true, 'youtube_urls' => ['https://youtube.test/demo'], + // The category's public id, which an app groups a parent category's products by. + 'category' => 'category_123', ])->and($data['images']->all())->toBe(['https://cdn.test/product.png']) ->and($data['videos']->all())->toBe(['https://cdn.test/product.mp4']) ->and($data)->not->toHaveKeys([ diff --git a/server/tests/Unit/Models/ApiCacheDependenciesTest.php b/server/tests/Unit/Models/ApiCacheDependenciesTest.php index 4d08fc1b..8aef6dd6 100644 --- a/server/tests/Unit/Models/ApiCacheDependenciesTest.php +++ b/server/tests/Unit/Models/ApiCacheDependenciesTest.php @@ -56,6 +56,6 @@ function declaredApiCacheDependencies(Model $model): array 'catalog' => [Catalog::class, ['categories', 'categories.products', 'hours', 'assignments', 'stores', 'food_trucks']], 'store' => [Store::class, ['reviews', 'networks']], 'food truck' => [FoodTruck::class, ['store', 'store.networks', 'network', 'vehicle', 'vehicle.driver', 'serviceArea', 'serviceArea.zones', 'zone', 'catalogs']], - 'product' => [Product::class, ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'store', 'store.networks', 'catalogCategories']], + 'product' => [Product::class, ['addonCategories', 'addonCategories.category', 'addonCategories.category.addons', 'variants', 'variants.options', 'store', 'store.networks', 'catalogCategories', 'category']], 'addon category' => [AddonCategory::class, ['addons']], ]); diff --git a/server/tests/Unit/Support/NestedCategoriesTest.php b/server/tests/Unit/Support/NestedCategoriesTest.php new file mode 100644 index 00000000..d7aa3bc5 --- /dev/null +++ b/server/tests/Unit/Support/NestedCategoriesTest.php @@ -0,0 +1,350 @@ +connection('mysql'); +} + +/** + * A store's product categories, Drinks › Coffee › Cold brew and Drinks › Tea, plus Food and + * another store's Snacks; and a few products filed at each level. + */ +function nestedCategoriesSchema(): void +{ + $schema = nestedCategoriesDb()->getSchemaBuilder(); + foreach (['categories', 'products', 'product_addon_categories', 'product_variants', 'files', 'catalog_category_products'] as $table) { + $schema->dropIfExists($table); + } + + $schema->create('categories', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('owner_type')->nullable(); + $table->string('parent_uuid')->nullable(); + $table->string('icon_file_uuid')->nullable(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->text('tags')->nullable(); + $table->text('translations')->nullable(); + $table->text('meta')->nullable(); + $table->string('icon')->nullable(); + $table->string('slug')->nullable(); + $table->string('for')->nullable(); + $table->integer('order')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('products', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('category_uuid')->nullable(); + $table->string('store_uuid')->nullable(); + $table->boolean('is_available')->default(true); + $table->string('status')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + foreach (['product_addon_categories', 'product_variants'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('product_uuid')->nullable(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + $schema->create('files', function ($table) { + $table->increments('id'); + $table->string('subject_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('catalog_category_products', function ($table) { + $table->string('uuid')->nullable(); + $table->string('catalog_category_uuid')->nullable(); + $table->string('product_uuid')->nullable(); + $table->integer('price')->nullable(); + $table->boolean('is_available')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $category = fn (string $uuid, string $name, ?string $parent, string $owner = 'store_uuid', string $for = 'storefront_product', int $order = 0) => [ + 'uuid' => $uuid, + 'public_id' => 'category_' . $uuid, + 'company_uuid' => 'company_uuid', + 'owner_uuid' => $owner, + 'parent_uuid' => $parent, + 'name' => $name, + 'for' => $for, + 'order' => $order, + 'created_at' => now(), + 'updated_at' => now(), + ]; + nestedCategoriesDb()->table('categories')->insert([ + $category('drinks', 'Drinks', null), + $category('coffee', 'Coffee', 'drinks', order: 1), + $category('cold_brew', 'Cold brew', 'coffee'), + $category('tea', 'Tea', 'drinks', order: 2), + $category('food', 'Food', null, order: 3), + $category('snacks', 'Snacks', null, 'other_store'), + ]); + + $product = fn (string $uuid, string $category) => ['uuid' => $uuid, 'public_id' => 'product_' . $uuid, 'category_uuid' => $category, 'store_uuid' => 'store_uuid', 'is_available' => true, 'status' => 'published']; + nestedCategoriesDb()->table('products')->insert([ + $product('flat_white', 'coffee'), + $product('nitro', 'cold_brew'), + $product('chai', 'tea'), + $product('croissant', 'food'), + ]); +} + +function nestedCategoriesRequest(array $input = [], string $uri = '/v1/categories'): Request +{ + $request = Request::create($uri, 'GET', $input); + $request->setLaravelSession(new SessionStore('nested-categories-test', new ArraySessionHandler(120))); + // The API cache keys a list by the request's session (its store or network), as in production. + foreach (['company', 'storefront_store', 'storefront_network'] as $key) { + $request->session()->put($key, session($key)); + } + app()->instance('request', $request); + + return $request; +} + +function nestedCategory(string $uuid): Category +{ + return Category::where('uuid', $uuid)->firstOrFail(); +} + +/** What a validation failure says. */ +function nestedCategoriesRefusal(Closure $callback): ?string +{ + try { + $callback(); + } catch (ValidationException $exception) { + return $exception->errors()['parent'][0] ?? null; + } + + return null; +} + +beforeEach(function () { + nestedCategoriesSchema(); + session(['company' => 'company_uuid', 'storefront_store' => 'store_uuid', 'storefront_network' => null]); + app()->instance('responsecache', new class { + public function clear(): void + { + } + }); + Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app())); + Model::clearBootedModels(); +}); + +afterEach(function () { + Category::flushEventListeners(); + CatalogCategory::flushEventListeners(); + Model::unsetEventDispatcher(); + Model::clearBootedModels(); +}); + +test('a category takes in every category under it, and knows its level, ancestors and the levels under it', function () { + expect(CategoryTree::withDescendants('drinks'))->toBe(['drinks', 'coffee', 'tea', 'cold_brew']) + ->and(CategoryTree::withDescendants(nestedCategory('coffee')))->toBe(['coffee', 'cold_brew']) + ->and(CategoryTree::withDescendants('food'))->toBe(['food']) + ->and(CategoryTree::depth(nestedCategory('cold_brew')))->toBe(3) + ->and(collect(CategoryTree::ancestors(nestedCategory('cold_brew')))->pluck('uuid')->all())->toBe(['coffee', 'drinks']) + ->and(CategoryTree::height(nestedCategory('drinks')))->toBe(2) + ->and(CategoryTree::height(nestedCategory('tea')))->toBe(0) + ->and(CategoryTree::nests(nestedCategory('drinks')))->toBeTrue() + ->and(CategoryTree::nests(null))->toBeFalse(); + + // A parent that is gone ends the walk up. + $orphan = (new Category())->forceFill(['uuid' => 'orphan', 'parent_uuid' => 'gone']); + expect(CategoryTree::ancestors($orphan))->toBe([]); +}); + +test('a parent must be in the same store, not under the category itself, and within three levels', function () { + $drinks = nestedCategory('drinks'); + $coldBrew = nestedCategory('cold_brew'); + + expect(nestedCategoriesRefusal(fn () => CategoryTree::assertValidParent($drinks, nestedCategory('snacks')))) + ->toBe('A category can only be placed under a category of the same store or menu.') + ->and(nestedCategoriesRefusal(fn () => CategoryTree::assertValidParent($drinks, $coldBrew))) + ->toBe('A category cannot be placed under itself or one of its own subcategories.') + // Food under Cold brew would be a fourth level. + ->and(nestedCategoriesRefusal(fn () => CategoryTree::assertValidParent(nestedCategory('food'), $coldBrew))) + ->toBe('Categories nest at most three levels deep.') + // Coffee (with Cold brew under it) under Tea would put Cold brew at the fourth level. + ->and(nestedCategoriesRefusal(fn () => CategoryTree::assertValidParent(nestedCategory('coffee'), nestedCategory('tea')))) + ->toBe('Categories nest at most three levels deep.') + // Food under Tea, and a new category under Coffee, are fine; so is no parent at all. + ->and(nestedCategoriesRefusal(fn () => CategoryTree::assertValidParent(nestedCategory('food'), nestedCategory('tea'))))->toBeNull() + ->and(nestedCategoriesRefusal(fn () => CategoryTree::assertValidParent((new Category())->forceFill(['owner_uuid' => 'store_uuid', 'for' => 'storefront_product']), nestedCategory('coffee'))))->toBeNull() + ->and(nestedCategoriesRefusal(fn () => CategoryTree::assertValidParent($drinks, null)))->toBeNull(); +}); + +test('a whole tree is checked before it is written, and parents are placed before their children', function () { + $valid = ['lunch' => null, 'tacos' => 'lunch', 'veggie' => 'tacos', 'drinks' => null]; + + expect(nestedCategoriesRefusal(fn () => CategoryTree::assertValidTree($valid)))->toBeNull() + ->and(CategoryTree::topDown(['veggie' => 'tacos', 'tacos' => 'lunch', 'lunch' => null]))->toBe(['lunch', 'tacos', 'veggie']) + ->and(nestedCategoriesRefusal(fn () => CategoryTree::assertValidTree(['tacos' => 'gone']))) + ->toBe('A category was placed under a category that is not in this menu.') + ->and(nestedCategoriesRefusal(fn () => CategoryTree::assertValidTree(['a' => 'b', 'b' => 'a']))) + ->toBe('A category cannot be placed under itself or one of its own subcategories.') + ->and(nestedCategoriesRefusal(fn () => CategoryTree::assertValidTree(['a' => null, 'b' => 'a', 'c' => 'b', 'd' => 'c']))) + ->toBe('Categories nest at most three levels deep.'); +}); + +test('saving a category under a parent is refused when the tree would break, and removing one moves its subcategories up', function () { + Category::observe(CategoryTreeObserver::class); + + $food = nestedCategory('food'); + $food->parent_uuid = 'cold_brew'; + $tooDeep = nestedCategoriesRefusal(fn () => $food->save()); + + $food->parent_uuid = 'gone'; + $missing = nestedCategoriesRefusal(fn () => $food->save()); + + $tea = nestedCategory('tea'); + $tea->parent_uuid = 'food'; + $tea->save(); + + // A category that does not nest (a network's) is left alone, as is one moved to the top. + $network = (new Category())->forceFill(['uuid' => 'network_category', 'for' => 'storefront_network', 'parent_uuid' => 'gone']); + $network->save(); + $coffee = nestedCategory('coffee'); + $coffee->parent_uuid = null; + $coffee->save(); + + // Removing Coffee moves Cold brew up to Coffee's parent (the top level now). + nestedCategory('coffee')->delete(); + $network->delete(); + + expect($tooDeep)->toBe('Categories nest at most three levels deep.') + ->and($missing)->toBe('The parent category no longer exists.') + ->and(nestedCategoriesDb()->table('categories')->where('uuid', 'tea')->value('parent_uuid'))->toBe('food') + ->and(nestedCategoriesDb()->table('categories')->where('uuid', 'food')->value('parent_uuid'))->toBeNull() + ->and(nestedCategoriesDb()->table('categories')->where('uuid', 'cold_brew')->value('parent_uuid'))->toBeNull() + ->and(nestedCategoriesDb()->table('categories')->where('uuid', 'network_category')->value('parent_uuid'))->toBe('gone'); +}); + +test('a store\'s categories say their parent and level, and can bring their subcategories and every product under them', function () { + $flat = (new CategoryController())->query(nestedCategoriesRequest())->resource->keyBy('uuid'); + $tree = (new CategoryController())->query(nestedCategoriesRequest(['parents_only' => 1, 'with_subcategories' => 1]))->resource; + $direct = (new CategoryController())->query(nestedCategoriesRequest(['parents_only' => 1, 'with_products' => 1]))->resource->keyBy('uuid'); + $rollup = (new CategoryController())->query(nestedCategoriesRequest(['parents_only' => 1, 'with_products' => 1, 'rollup' => 1]))->resource->keyBy('uuid'); + $products = fn ($category) => $category->products->map(fn ($product) => $product->resource->uuid)->sort()->values()->all(); + + expect($flat->keys()->sort()->values()->all())->toBe(['coffee', 'cold_brew', 'drinks', 'food', 'tea']) + ->and($flat['cold_brew']->parent_public_id)->toBe('category_coffee') + ->and($flat['cold_brew']->depth)->toBe(3) + ->and($flat['drinks']->parent_public_id)->toBeNull() + ->and($flat['drinks']->depth)->toBe(1) + ->and($tree->pluck('uuid')->all())->toBe(['drinks', 'food']) + ->and($tree->firstWhere('uuid', 'drinks')->subCategories->pluck('uuid')->all())->toBe(['coffee', 'tea']) + // Without rollup a category brings only its own products; with it, everything under it. + ->and($products($direct['drinks']))->toBe([]) + ->and($products($rollup['drinks']))->toBe(['chai', 'flat_white', 'nitro']) + ->and($products($rollup['food']))->toBe(['croissant']); +}); + +test('the category resource says a category\'s parent and level, and lists its subcategories', function () { + $request = nestedCategoriesRequest(['with_subcategories' => 1]); + $drinks = nestedCategory('drinks'); + $drinks->setAttribute('parent_public_id', null); + $drinks->setAttribute('depth', 1); + $drinks->setRelation('subCategories', collect([nestedCategory('coffee')])); + $coffee = nestedCategory('coffee')->setRelation('parentCategory', nestedCategory('drinks')); + + $data = (new CategoryResource($drinks))->resolve($request); + $loaded = (new CategoryResource($coffee))->resolve($request); + $plain = (new CategoryResource(nestedCategory('tea')))->resolve(nestedCategoriesRequest()); + $noRelated = (new CategoryResource(nestedCategory('tea')))->resolve($request); + + expect($data['parent'])->toBeNull() + ->and($data['depth'])->toBe(1) + ->and(collect($data['subcategories'])->map(fn ($sub) => $sub->resource->uuid)->all())->toBe(['coffee']) + ->and($loaded['parent'])->toBe('category_drinks') + ->and($plain)->not->toHaveKeys(['parent', 'depth', 'subcategories']) + ->and(collect($noRelated['subcategories'])->all())->toBe([]); +}); + +test('a network\'s categories say their parent and level too', function () { + session(['storefront_store' => null, 'storefront_network' => 'network_uuid']); + nestedCategoriesDb()->table('categories')->insert([ + ['uuid' => 'eat', 'public_id' => 'category_eat', 'owner_uuid' => 'network_uuid', 'parent_uuid' => null, 'name' => 'Eat', 'for' => 'storefront_network'], + ['uuid' => 'noodles', 'public_id' => 'category_noodles', 'owner_uuid' => 'network_uuid', 'parent_uuid' => 'eat', 'name' => 'Noodles', 'for' => 'storefront_network'], + ]); + + $categories = (new CategoryController())->query(nestedCategoriesRequest())->resource->keyBy('uuid'); + + expect($categories['noodles']->parent_public_id)->toBe('category_eat') + ->and($categories['noodles']->depth)->toBe(2); +}); + +test('a catalog saves its menu as a tree, new subcategories found by their key, and reports each parent publicly', function () { + CatalogCategory::observe(CategoryTreeObserver::class); + $catalog = new Catalog(['uuid' => 'catalog_uuid', 'company_uuid' => 'company_uuid']); + $catalog->setRelation('categories', collect()); + + $catalog->setCategories([ + ['key' => 'lunch', 'name' => 'Lunch', 'products' => []], + ['key' => 'tacos', 'parent_key' => 'lunch', 'name' => 'Tacos', 'products' => []], + ['key' => 'veggie', 'parent_key' => 'tacos', 'name' => 'Vegetarian', 'products' => []], + ['key' => 'drinks', 'name' => 'Drinks', 'products' => []], + ]); + + $saved = fn () => CatalogCategory::where('owner_uuid', 'catalog_uuid')->get()->keyBy('name'); + $first = $saved(); + // The models are updated in place by the next save, so keep what this one wrote. + $firstParents = $first->map(fn ($category) => $category->parent_uuid)->all(); + $catalog = new Catalog(['uuid' => 'catalog_uuid', 'company_uuid' => 'company_uuid']); + $catalog->setRelation('categories', $first->values()); + + // Move Vegetarian up under Lunch and Drinks under Vegetarian, in one save, by uuid. + $catalog->setCategories([ + ['uuid' => $first['Lunch']->uuid, 'name' => 'Lunch', 'products' => []], + ['uuid' => $first['Tacos']->uuid, 'parent_uuid' => $first['Lunch']->uuid, 'name' => 'Tacos', 'products' => []], + ['uuid' => $first['Vegetarian']->uuid, 'parent_uuid' => $first['Lunch']->uuid, 'name' => 'Vegetarian', 'products' => []], + ['uuid' => $first['Drinks']->uuid, 'parent_uuid' => $first['Vegetarian']->uuid, 'name' => 'Drinks', 'products' => []], + ]); + $second = $saved(); + + $tooDeep = nestedCategoriesRefusal(fn () => $catalog->setCategories([ + ['key' => 'a', 'name' => 'A'], + ['key' => 'b', 'parent_key' => 'a', 'name' => 'B'], + ['key' => 'c', 'parent_key' => 'b', 'name' => 'C'], + ['key' => 'd', 'parent_key' => 'c', 'name' => 'D'], + ])); + + $menu = new Catalog(['uuid' => 'catalog_uuid', 'public_id' => 'catalog_menu', 'name' => 'Menu']); + $menu->setRelation('categories', $second->values()); + $public = collect((new CatalogResource($menu))->resolve(nestedCategoriesRequest([], '/v1/catalogs'))['categories']) + ->map(fn ($category) => $category->resolve(nestedCategoriesRequest([], '/v1/catalogs'))); + + expect($firstParents['Tacos'])->toBe($first['Lunch']->uuid) + ->and($firstParents['Vegetarian'])->toBe($first['Tacos']->uuid) + ->and($firstParents['Lunch'])->toBeNull() + ->and($firstParents['Drinks'])->toBeNull() + ->and($second['Vegetarian']->parent_uuid)->toBe($second['Lunch']->uuid) + ->and($second['Drinks']->parent_uuid)->toBe($second['Vegetarian']->uuid) + ->and($tooDeep)->toBe('Categories nest at most three levels deep.') + ->and($public->firstWhere('name', 'Drinks')['parent'])->toBe($second['Vegetarian']->public_id) + ->and($public->firstWhere('name', 'Lunch')['parent'])->toBeNull(); +}); From 4217026827b1cd95f59adab9a5242ce6e605ab6c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 19:27:49 +0800 Subject: [PATCH 2/4] fix(catalogs): edit a catalog category's name, description and icon in a modal The catalog editor's pencil button only renamed a category inline, so a menu category could not get an icon. It now opens the product category modal on the catalog category (name, description, icon upload). Changes stay on the page until the catalog is saved, like every other editor change; cancel restores them. A set icon shows in the category row, and Duplicate copies each category's description and icon. Catalog::setCategories() now writes description, icon, icon_color and icon_file_uuid when the payload carries them (left alone when it does not), and the catalog category resource returns icon and icon_color, plus icon_file_uuid on internal requests, so the editor's save round trip keeps them. --- addon/controllers/catalogs/index/edit.js | 77 ++++++--- addon/styles/storefront-engine.css | 10 +- addon/templates/catalogs/index/edit.hbs | 29 ++-- server/src/Http/Resources/CatalogCategory.php | 3 + server/src/Models/Catalog.php | 8 +- .../Resources/SimpleResourceContractsTest.php | 51 +++--- .../Models/StorefrontModelContractsTest.php | 108 ++++++++++++ tests/dummy/app/models/category.js | 4 + .../modals/create-product-category-test.js | 23 ++- tests/test-helper.js | 7 +- .../catalogs/index/edit-category-test.js | 161 ++++++++++++++++++ translations/en-us.yaml | 8 +- 12 files changed, 421 insertions(+), 68 deletions(-) create mode 100644 tests/unit/controllers/catalogs/index/edit-category-test.js diff --git a/addon/controllers/catalogs/index/edit.js b/addon/controllers/catalogs/index/edit.js index 09ce53dd..065ad356 100644 --- a/addon/controllers/catalogs/index/edit.js +++ b/addon/controllers/catalogs/index/edit.js @@ -2,9 +2,12 @@ import Controller from '@ember/controller'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { dasherize } from '@ember/string'; import { task } from 'ember-concurrency'; const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; +/** What the category modal edits; a clone or cancel carries or restores exactly these. */ +const CATEGORY_DETAILS = ['description', 'icon', 'icon_color', 'icon_file_uuid', 'icon_url']; export default class CatalogsIndexEditController extends Controller { @service store; @@ -13,6 +16,8 @@ export default class CatalogsIndexEditController extends Controller { @service modalsManager; @service hostRouter; @service crud; + @service fetch; + @service currentUser; @tracked catalog; @tracked products = []; @tracked stores = []; @@ -22,8 +27,6 @@ export default class CatalogsIndexEditController extends Controller { @tracked pickerQuery = ''; @tracked pickerSelection = []; @tracked newCategoryName = ''; - @tracked renamingCategory = null; - @tracked renameValue = ''; @tracked revision = 0; /** The open editor section, kept in the URL so links can land on Served by or Hours. */ queryParams = [{ activeSection: 'section' }]; @@ -223,6 +226,10 @@ export default class CatalogsIndexEditController extends Controller { categories: this.categories.map((category) => ({ id: category.id, name: category.name, + description: category.description, + icon: category.icon, + icon_color: category.icon_color, + icon_file_uuid: category.icon_file_uuid, products: (category.products?.toArray?.() ?? Array.from(category.products ?? [])).map((product) => product.id), overrides: this.overridesFor(category), })), @@ -299,28 +306,55 @@ export default class CatalogsIndexEditController extends Controller { this.touch(); } - @action startRename(category) { - this.renamingCategory = category; - this.renameValue = category.name ?? ''; - } + /** + * Edit a category's name, description and icon in the category modal. Like every other + * edit here the changes stay on the page until the catalog is saved; cancel puts them back. + */ + @action editCategory(category) { + const before = category.getProperties('name', ...CATEGORY_DETAILS); + + return this.modalsManager.show('modals/create-product-category', { + title: this.intl.t('storefront.catalogs.editor.edit-category-title', { name: category.name }), + acceptButtonText: this.intl.t('storefront.catalogs.editor.apply-category'), + acceptButtonIcon: 'check', + declineButtonIcon: 'times', + declineButtonIconPrefix: 'fas', + category, + uploadNewPhoto: (file) => this.uploadCategoryIcon(category, file), + confirm: (modal) => { + const name = String(category.name ?? '').trim(); - @action setRenameValue(event) { - this.renameValue = event.target.value; + if (!name) { + return this.notifications.warning(this.intl.t('storefront.catalogs.editor.category-name-required')); + } + + category.set('name', name); + this.touch(); + modal.done(); + }, + decline: (modal) => { + category.setProperties(before); + modal.done(); + }, + }); } - @action commitRename(event) { - event?.preventDefault?.(); + uploadCategoryIcon(category, file) { + const companyId = category.company_uuid ?? this.currentUser.companyId; + const params = { + path: `uploads/${companyId}/catalog-category-icon/${dasherize(category.name || 'category')}`, + subject_type: 'category', + type: 'category_icon', + }; - if (this.renamingCategory && this.renameValue.trim()) { - this.renamingCategory.set('name', this.renameValue.trim()); - this.touch(); + // A category added on this page has no uuid until the catalog is saved. + if (!category.isNew && category.id) { + params.subject_uuid = category.id; } - this.renamingCategory = null; - } - - @action cancelRename() { - this.renamingCategory = null; + return this.fetch.uploadFile.perform(file, params, (uploadedFile) => { + category.setProperties({ icon_file_uuid: uploadedFile.id, icon_url: uploadedFile.url }); + }); } @action moveCategory(category, direction) { @@ -495,7 +529,12 @@ export default class CatalogsIndexEditController extends Controller { }); this.categories.forEach((category) => { - const clone = this.store.createRecord('catalog-category', { name: category.name, for: 'storefront_catalog', order: category.order }); + const clone = this.store.createRecord('catalog-category', { + ...category.getProperties(CATEGORY_DETAILS), + name: category.name, + for: 'storefront_catalog', + order: category.order, + }); (category.products?.toArray?.() ?? []).forEach((product) => clone.products.pushObject(product)); copy.categories.pushObject(clone); }); diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index d6f0da31..27b495c0 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -4842,7 +4842,15 @@ body[data-theme='dark'] .storefront-catalog-category__select { display: flex; } -.storefront-catalog-category__rename { +.storefront-catalog-category__icon { + flex: 0 0 auto; + width: 1.25rem; + height: 1.25rem; + border-radius: 0.25rem; + object-fit: cover; +} + +.storefront-catalog-category__icon + .storefront-catalog-category__name { flex: 1 1 auto; } diff --git a/addon/templates/catalogs/index/edit.hbs b/addon/templates/catalogs/index/edit.hbs index 4455b542..fc45652c 100644 --- a/addon/templates/catalogs/index/edit.hbs +++ b/addon/templates/catalogs/index/edit.hbs @@ -29,22 +29,19 @@
{{t "storefront.catalogs.editor.sections.categories"}}
{{#each this.categoryRows as |row|}}
- {{#if (eq this.renamingCategory row.category)}} -
- -
- {{else}} - -
-
- {{/if}} + +
+
{{else}}
{{t "storefront.catalogs.card.no-categories"}}
diff --git a/server/src/Http/Resources/CatalogCategory.php b/server/src/Http/Resources/CatalogCategory.php index e51a8430..1bd2e9b1 100644 --- a/server/src/Http/Resources/CatalogCategory.php +++ b/server/src/Http/Resources/CatalogCategory.php @@ -27,6 +27,9 @@ public function toArray($request) 'owner_uuid' => $this->when(Http::isInternalRequest(), $this->owner_uuid), 'name' => $this->name, 'description' => $this->description, + 'icon' => $this->icon, + 'icon_color' => $this->icon_color, + 'icon_file_uuid' => $this->when(Http::isInternalRequest(), $this->icon_file_uuid), 'icon_url' => $this->icon_url, 'tags' => data_get($this, 'tags', []), 'meta' => data_get($this, 'meta', Utils::createObject()), diff --git a/server/src/Models/Catalog.php b/server/src/Models/Catalog.php index 85422091..9fe14d6e 100644 --- a/server/src/Models/Catalog.php +++ b/server/src/Models/Catalog.php @@ -198,6 +198,8 @@ public function setCategories(array $categories = []): Catalog $overrides = is_array($categoryData) && array_key_exists('product_overrides', $categoryData) ? (array) ($categoryData['product_overrides'] ?? []) : null; $name = data_get($categoryData, 'name'); $order = (int) data_get($categoryData, 'order', $index); + // Description and icon are written only when sent, so a payload without them keeps them. + $details = array_intersect_key((array) $categoryData, array_flip(['description', 'icon', 'icon_color', 'icon_file_uuid'])); // Try to find an existing category $categoryRecord = null; @@ -207,16 +209,16 @@ public function setCategories(array $categories = []): Catalog if ($categoryRecord) { // 2a) Update existing category - $categoryRecord->update(['name' => $name, 'order' => $order]); + $categoryRecord->update(array_merge(['name' => $name, 'order' => $order], $details)); } else { // 2b) Create a new category - $categoryRecord = CatalogCategory::create([ + $categoryRecord = CatalogCategory::create(array_merge([ 'name' => $name, 'order' => $order, 'company_uuid' => $this->company_uuid, 'owner_uuid' => $this->uuid, 'owner_type' => get_class($this), - ]); + ], $details)); } // 3) Update products for this category diff --git a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php index b6052dcf..7b9a4903 100644 --- a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php @@ -163,11 +163,11 @@ public function uri(): string 'public_id' => 'review_123', 'customer_uuid' => 'customer_uuid', 'order_uuid' => 'order_uuid', - 'rating' => 5, - 'content' => 'Excellent service', - 'slug' => 'excellent-service', - 'created_at' => '2026-01-01', - 'updated_at' => '2026-01-02', + 'rating' => 5, + 'content' => 'Excellent service', + 'slug' => 'excellent-service', + 'created_at' => '2026-01-01', + 'updated_at' => '2026-01-02', ], [ 'subject' => storefrontResourceModel(['id' => 99, 'public_id' => 'store_123']), 'customer' => $customer, @@ -197,22 +197,25 @@ public function uri(): string test('catalog and category resources expose nested commerce navigation', function () { $product = storefrontResourceModel(['public_id' => 'product_123']); $catalogCategory = storefrontResourceModel([ - 'id' => 5, - 'uuid' => 'catalog_category_uuid', - 'public_id' => 'catalog_category_123', - 'company_uuid' => 'company_uuid', - 'parent_uuid' => null, - 'store_uuid' => 'store_uuid', - 'owner_uuid' => 'catalog_uuid', - 'name' => 'Coffee', - 'description' => 'Coffee products', - 'icon_url' => 'https://cdn.test/coffee.png', - 'tags' => ['drinks'], - 'meta' => ['featured' => true], - 'for' => 'catalog', - 'order' => 1, - 'created_at' => '2026-01-01', - 'updated_at' => '2026-01-02', + 'id' => 5, + 'uuid' => 'catalog_category_uuid', + 'public_id' => 'catalog_category_123', + 'company_uuid' => 'company_uuid', + 'parent_uuid' => null, + 'store_uuid' => 'store_uuid', + 'owner_uuid' => 'catalog_uuid', + 'name' => 'Coffee', + 'description' => 'Coffee products', + 'icon' => 'mug-hot', + 'icon_color' => '#7c3aed', + 'icon_file_uuid' => 'icon_file_uuid', + 'icon_url' => 'https://cdn.test/coffee.png', + 'tags' => ['drinks'], + 'meta' => ['featured' => true], + 'for' => 'catalog', + 'order' => 1, + 'created_at' => '2026-01-01', + 'updated_at' => '2026-01-02', ], [ 'products' => collect([$product]), ]); @@ -249,10 +252,14 @@ public function uri(): string 'id' => 'catalog_category_123', 'name' => 'Coffee', 'description' => 'Coffee products', + 'icon' => 'mug-hot', + 'icon_color' => '#7c3aed', + 'icon_url' => 'https://cdn.test/coffee.png', 'tags' => ['drinks'], 'meta' => ['featured' => true], 'order' => 1, - ])->and($categoryData['products'])->toHaveCount(1); + ])->and($categoryData)->not->toHaveKey('icon_file_uuid') + ->and($categoryData['products'])->toHaveCount(1); }); test('category resource controls optional products and nested categories', function () { diff --git a/server/tests/Unit/Models/StorefrontModelContractsTest.php b/server/tests/Unit/Models/StorefrontModelContractsTest.php index 9059e7ab..a1f8d481 100644 --- a/server/tests/Unit/Models/StorefrontModelContractsTest.php +++ b/server/tests/Unit/Models/StorefrontModelContractsTest.php @@ -103,6 +103,114 @@ ->and($connection->table('categories')->where('name', 'New category')->exists())->toBeTrue(); }); +test('catalog categories keep the description and icon sent with the catalog', function () { + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + $schema->dropIfExists('categories'); + $schema->dropIfExists('catalog_category_products'); + $schema->create('categories', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('owner_type')->nullable(); + $table->string('icon_file_uuid')->nullable(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('icon')->nullable(); + $table->string('icon_color')->nullable(); + $table->string('for')->nullable(); + $table->integer('order')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('catalog_category_products', function ($table) { + $table->string('uuid')->nullable(); + $table->string('catalog_category_uuid')->nullable(); + $table->string('product_uuid')->nullable(); + $table->integer('price')->nullable(); + $table->boolean('is_available')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->dropIfExists('products'); + $schema->create('products', function ($table) { + $table->string('uuid')->primary(); + $table->timestamp('deleted_at')->nullable(); + }); + + $editedUuid = '71000000-0000-4000-8000-000000000001'; + $untouchedUuid = '71000000-0000-4000-8000-000000000002'; + $clearedUuid = '71000000-0000-4000-8000-000000000003'; + $row = fn (string $uuid, string $name) => [ + 'uuid' => $uuid, + 'company_uuid' => 'company_uuid', + 'owner_uuid' => 'catalog_icon_uuid', + 'owner_type' => Catalog::class, + 'name' => $name, + 'description' => 'Old description', + 'icon' => 'utensils', + 'icon_color' => '#000000', + 'icon_file_uuid' => '72000000-0000-4000-8000-000000000001', + 'created_at' => now(), + 'updated_at' => now(), + ]; + $connection->table('categories')->insert([$row($editedUuid, 'Burgers'), $row($untouchedUuid, 'Sides'), $row($clearedUuid, 'Drinks')]); + + $existing = CatalogCategory::whereIn('uuid', [$editedUuid, $untouchedUuid, $clearedUuid])->orderBy('id')->get(); + $existing->each(fn (CatalogCategory $category) => $category->setRelation('products', collect())); + $catalog = new Catalog(['uuid' => 'catalog_icon_uuid', 'company_uuid' => 'company_uuid']); + $catalog->setRelation('categories', $existing); + + $catalog->setCategories([ + [ + 'uuid' => $editedUuid, + 'name' => 'Burgers', + 'description' => 'Smash burgers', + 'icon' => 'burger', + 'icon_color' => '#f97316', + 'icon_file_uuid' => '72000000-0000-4000-8000-000000000002', + 'icon_url' => 'https://cdn.test/ignored.png', + 'products' => [], + ], + ['uuid' => $untouchedUuid, 'name' => 'Sides', 'products' => []], + ['uuid' => $clearedUuid, 'name' => 'Drinks', 'description' => null, 'icon' => null, 'icon_color' => null, 'icon_file_uuid' => null, 'products' => []], + ['name' => 'Desserts', 'description' => 'Sweet things', 'icon' => 'ice-cream', 'icon_color' => '#ec4899', 'icon_file_uuid' => '72000000-0000-4000-8000-000000000003', 'products' => []], + ]); + + $fields = fn (string $column, string $value) => (array) $connection->table('categories')->where($column, $value)->first(['name', 'description', 'icon', 'icon_color', 'icon_file_uuid', 'order']); + + expect($fields('uuid', $editedUuid))->toBe([ + 'name' => 'Burgers', + 'description' => 'Smash burgers', + 'icon' => 'burger', + 'icon_color' => '#f97316', + 'icon_file_uuid' => '72000000-0000-4000-8000-000000000002', + 'order' => 0, + ])->and($fields('uuid', $untouchedUuid))->toBe([ + 'name' => 'Sides', + 'description' => 'Old description', + 'icon' => 'utensils', + 'icon_color' => '#000000', + 'icon_file_uuid' => '72000000-0000-4000-8000-000000000001', + 'order' => 1, + ])->and($fields('uuid', $clearedUuid))->toBe([ + 'name' => 'Drinks', + 'description' => null, + 'icon' => null, + 'icon_color' => null, + 'icon_file_uuid' => null, + 'order' => 2, + ])->and($fields('name', 'Desserts'))->toBe([ + 'name' => 'Desserts', + 'description' => 'Sweet things', + 'icon' => 'ice-cream', + 'icon_color' => '#ec4899', + 'icon_file_uuid' => '72000000-0000-4000-8000-000000000003', + 'order' => 3, + ]); +}); + test('catalog category exposes catalog product and polymorphic ownership relationships', function () { expect((new CatalogCategory())->owner())->toBeInstanceOf(MorphTo::class) ->and((new CatalogCategory())->catalog())->toBeInstanceOf(BelongsTo::class) diff --git a/tests/dummy/app/models/category.js b/tests/dummy/app/models/category.js index eb704177..58719231 100644 --- a/tests/dummy/app/models/category.js +++ b/tests/dummy/app/models/category.js @@ -5,7 +5,11 @@ export default class CategoryModel extends Model { @attr('string') name; @attr('string') slug; @attr('string') description; + @attr('string') icon; + @attr('string') icon_color; + @attr('string') icon_file_uuid; @attr('string') icon_url; + @attr('string') company_uuid; @attr('string') owner_uuid; @attr('string') owner_type; @attr('string') parent_uuid; diff --git a/tests/integration/components/modals/create-product-category-test.js b/tests/integration/components/modals/create-product-category-test.js index 491bbbd5..299232dd 100644 --- a/tests/integration/components/modals/create-product-category-test.js +++ b/tests/integration/components/modals/create-product-category-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { fillIn, render, settled } from '@ember/test-helpers'; +import { run } from '@ember/runloop'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | modals/create-product-category', function (hooks) { @@ -21,4 +22,24 @@ module('Integration | Component | modals/create-product-category', function (hoo assert.dom('.storefront-product-category-form__media').exists(); assert.dom('input[type="text"]').hasValue('Lunch'); }); + + test('it edits a catalog category name, description and icon', async function (assert) { + const store = this.owner.lookup('service:store'); + const category = run(() => store.createRecord('catalog-category', { name: 'Burgers', description: 'Old', icon_url: 'https://cdn.test/burgers.png' })); + this.set('options', { category, uploadNewPhoto() {} }); + + await render(hbs``); + + const [name, description] = this.element.querySelectorAll('input[type="text"]'); + assert.dom(name).hasValue('Burgers'); + assert.dom(description).hasValue('Old'); + assert.dom('.storefront-product-category-form__image').hasAttribute('src', 'https://cdn.test/burgers.png'); + + await fillIn(description, 'Smash burgers'); + assert.strictEqual(category.description, 'Smash burgers', 'the form writes to the catalog category'); + + run(() => category.set('icon_url', 'https://cdn.test/new.png')); + await settled(); + assert.dom('.storefront-product-category-form__image').hasAttribute('src', 'https://cdn.test/new.png', 'an uploaded icon shows at once'); + }); }); diff --git a/tests/test-helper.js b/tests/test-helper.js index f4c0486d..6a40779b 100644 --- a/tests/test-helper.js +++ b/tests/test-helper.js @@ -16,10 +16,11 @@ if (typeof window.define === 'function' && !window.requirejs?.entries?.['@fleetb }); } -// The addon-category model extends the console's category model; the dummy app has no console. +// The addon-category and catalog-category models extend the console's category model; the dummy +// app has no console, so they extend its stand-in, which carries the category attributes. if (typeof window.define === 'function' && !window.requirejs?.entries?.['@fleetbase/console/models/category']) { - window.define('@fleetbase/console/models/category', ['exports', '@ember-data/model'], function (exports, model) { - exports.default = class CategoryModel extends model.default {}; + window.define('@fleetbase/console/models/category', ['exports', 'dummy/models/category'], function (exports, category) { + exports.default = category.default; }); } diff --git a/tests/unit/controllers/catalogs/index/edit-category-test.js b/tests/unit/controllers/catalogs/index/edit-category-test.js new file mode 100644 index 00000000..b4896f37 --- /dev/null +++ b/tests/unit/controllers/catalogs/index/edit-category-test.js @@ -0,0 +1,161 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import { run } from '@ember/runloop'; + +module('Unit | Controller | catalogs/index/edit | category modal', function (hooks) { + setupTest(hooks); + + function setup(owner, categoryAttributes = {}) { + const controller = owner.lookup('controller:catalogs/index/edit'); + const store = owner.lookup('service:store'); + const shown = []; + const toasts = []; + const uploads = []; + const category = run(() => + store.push( + store.normalize('catalog-category', { + uuid: 'cat-uuid-1', + name: 'Burgers', + description: 'Old', + icon_file_uuid: 'file-old', + icon_url: 'https://cdn.test/old.png', + company_uuid: 'company-1', + ...categoryAttributes, + }) + ) + ); + const catalog = run(() => store.createRecord('catalog', { name: 'Lunch' })); + + run(() => catalog.categories.pushObject(category)); + controller.catalog = catalog; + controller.snapshot(); + controller.intl = { t: (key, params) => (params?.name ? `${key}:${params.name}` : key) }; + controller.notifications = { warning: (message) => toasts.push(message), success: (message) => toasts.push(message), serverError: (error) => toasts.push(error.message) }; + controller.modalsManager = { show: (name, options) => shown.push({ name, options }) }; + controller.fetch = { + uploadFile: { + perform: (file, params, callback) => { + uploads.push(params); + callback({ id: 'file-new', url: 'https://cdn.test/new.png' }); + }, + }, + }; + controller.currentUser = { companyId: 'company-session' }; + + return { controller, category, shown, toasts, uploads }; + } + + function modal() { + const calls = []; + + return { calls, done: () => calls.push('done') }; + } + + test('edit opens the category modal on the catalog category with its title and actions', function (assert) { + const { controller, category, shown } = setup(this.owner); + + controller.editCategory(category); + + assert.strictEqual(shown.length, 1); + assert.strictEqual(shown[0].name, 'modals/create-product-category'); + assert.strictEqual(shown[0].options.category, category, 'the form edits the catalog category itself'); + assert.strictEqual(shown[0].options.title, 'storefront.catalogs.editor.edit-category-title:Burgers'); + assert.strictEqual(shown[0].options.acceptButtonText, 'storefront.catalogs.editor.apply-category'); + assert.strictEqual(typeof shown[0].options.uploadNewPhoto, 'function'); + }); + + test('apply keeps the edits, trims the name and leaves the catalog unsaved', function (assert) { + const { controller, category, shown } = setup(this.owner); + + controller.editCategory(category); + run(() => category.setProperties({ name: ' Smash burgers ', description: 'Two patties' })); + const handle = modal(); + shown[0].options.confirm(handle); + + assert.deepEqual(handle.calls, ['done']); + assert.strictEqual(category.name, 'Smash burgers'); + assert.strictEqual(category.description, 'Two patties'); + assert.true(controller.isDirty, 'the save bar offers to save the catalog'); + }); + + test('apply with an empty name warns and keeps the modal open', function (assert) { + const { controller, category, shown, toasts } = setup(this.owner); + + controller.editCategory(category); + run(() => category.set('name', ' ')); + const handle = modal(); + shown[0].options.confirm(handle); + + assert.deepEqual(handle.calls, [], 'the modal is not closed'); + assert.deepEqual(toasts, ['storefront.catalogs.editor.category-name-required']); + }); + + test('cancel puts the name, description and icon back', function (assert) { + const { controller, category, shown } = setup(this.owner); + + controller.editCategory(category); + run(() => category.setProperties({ name: 'Changed', description: 'Changed too' })); + shown[0].options.uploadNewPhoto({ name: 'icon.png' }); + const handle = modal(); + shown[0].options.decline(handle); + + assert.deepEqual(handle.calls, ['done']); + assert.strictEqual(category.name, 'Burgers'); + assert.strictEqual(category.description, 'Old'); + assert.strictEqual(category.icon_file_uuid, 'file-old'); + assert.strictEqual(category.icon_url, 'https://cdn.test/old.png'); + assert.false(controller.isDirty); + }); + + test('an uploaded icon is set on the category and linked to it', function (assert) { + const { controller, category, shown, uploads } = setup(this.owner); + + controller.editCategory(category); + shown[0].options.uploadNewPhoto({ name: 'icon.png' }); + + assert.deepEqual(uploads, [{ path: 'uploads/company-1/catalog-category-icon/burgers', subject_type: 'category', type: 'category_icon', subject_uuid: 'cat-uuid-1' }]); + assert.strictEqual(category.icon_file_uuid, 'file-new'); + assert.strictEqual(category.icon_url, 'https://cdn.test/new.png'); + assert.true(controller.isDirty, 'a new icon is an unsaved change'); + }); + + test('a category added on the page uploads its icon without a subject yet', function (assert) { + const { controller, uploads } = setup(this.owner); + const store = this.owner.lookup('service:store'); + const fresh = run(() => store.createRecord('catalog-category', { name: 'Hot Drinks' })); + + controller.uploadCategoryIcon(fresh, { name: 'icon.png' }); + + assert.deepEqual(uploads, [{ path: 'uploads/company-session/catalog-category-icon/hot-drinks', subject_type: 'category', type: 'category_icon' }]); + assert.strictEqual(fresh.icon_file_uuid, 'file-new'); + }); + + test('duplicating the catalog copies each category description and icon', async function (assert) { + const { controller } = setup(this.owner, { icon: 'burger', icon_color: '#f97316' }); + let saved; + + controller.store = { + createRecord: (type, attributes) => { + const record = { type, ...attributes, categories: [], products: [], save: () => Promise.resolve(record) }; + record.categories.pushObject = (item) => record.categories.push(item); + record.products.pushObject = (item) => record.products.push(item); + if (type === 'catalog') { + saved = record; + } + return record; + }, + }; + controller.hostRouter = { transitionTo: () => {} }; + + await controller.duplicate(); + + const [clone] = saved.categories; + assert.strictEqual(clone.name, 'Burgers'); + assert.strictEqual(clone.description, 'Old'); + assert.strictEqual(clone.icon, 'burger'); + assert.strictEqual(clone.icon_color, '#f97316'); + assert.strictEqual(clone.icon_file_uuid, 'file-old'); + assert.strictEqual(clone.icon_url, 'https://cdn.test/old.png'); + assert.strictEqual(clone.for, 'storefront_catalog'); + }); +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index b992fa8c..cf5872f2 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -211,7 +211,7 @@ storefront: name: Category name name-text: Enter a name for category description: Category description - description-text: Give your product category a brief description + description-text: Give your category a brief description icon: Category icon uploading-new: Upload new category icon create-store: @@ -453,7 +453,10 @@ storefront: cancel-new-button: Cancel new customer catalogs: editor: - rename-category: Rename category + edit-category: Edit category + edit-category-title: "Edit {name}" + apply-category: Apply + category-name-required: Give the category a name. back: All catalogs delete-catalog: Delete catalog sold-here: Sold here @@ -474,7 +477,6 @@ storefront: served-by: Served by details: Details new-category: New category - rename: Rename move-up: Move up move-down: Move down categories-note: The order here is the order the app shows. A category with no products is hidden in the app. From 42091742088a6c98990ba921ff9ad48bc3e173ff Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 19:42:46 +0800 Subject: [PATCH 3/4] feat(categories): nested product and catalog categories in the console - Product categories: the category form has a parent picker (the store's tree, minus the category itself, its subcategories and anything past three levels); the sidebar's rows offer New subcategory up to the second level; the category page's header shows its path and rolled-up product count; the product form names each category option by its path. - Bulk move to category now shows the store's categories in tree order (the modal ignored the categories it was given and showed the network picker). - Catalog editor: categories show as a tree with indent, outdent, add subcategory and move within siblings; removing one moves its subcategories up; new categories carry a key so a subcategory can sit under one before it is saved; a duplicated catalog keeps its tree. - A refused parent (the server's 422) is shown and the form stays open. --- .../modals/add-store-to-category.hbs | 14 +- .../modals/create-product-category.hbs | 2 +- .../storefront/product/category-form.hbs | 13 ++ .../storefront/product/category-form.js | 36 ++++ .../storefront/product/category-sidebar.hbs | 9 + .../storefront/product/category-sidebar.js | 8 + .../product/category-tree-select.hbs | 11 ++ .../product/category-tree-select.js | 24 +++ addon/components/storefront/product/form.hbs | 2 +- addon/components/storefront/product/form.js | 10 ++ addon/controllers/catalogs/index/edit.js | 163 ++++++++++++++++-- addon/controllers/products/index.js | 46 ++++- addon/controllers/products/index/category.js | 17 +- addon/models/catalog-category.js | 3 + addon/styles/storefront-engine.css | 40 +++++ addon/templates/catalogs/index/edit.hbs | 12 +- addon/templates/products/index.hbs | 1 + addon/utils/category-tree.js | 89 ++++++++++ .../product/category-tree-select.js | 1 + .../modals/add-store-to-category-test.js | 17 +- .../modals/create-product-category-test.js | 28 ++- .../product/category-sidebar-test.js | 32 ++++ .../controllers/catalogs/index/edit-test.js | 106 ++++++++++++ tests/unit/utils/category-tree-test.js | 68 ++++++++ translations/en-us.yaml | 12 +- 25 files changed, 728 insertions(+), 36 deletions(-) create mode 100644 addon/components/storefront/product/category-form.js create mode 100644 addon/components/storefront/product/category-tree-select.hbs create mode 100644 addon/components/storefront/product/category-tree-select.js create mode 100644 addon/utils/category-tree.js create mode 100644 app/components/storefront/product/category-tree-select.js create mode 100644 tests/unit/utils/category-tree-test.js diff --git a/addon/components/modals/add-store-to-category.hbs b/addon/components/modals/add-store-to-category.hbs index af0634b8..d198f233 100644 --- a/addon/components/modals/add-store-to-category.hbs +++ b/addon/components/modals/add-store-to-category.hbs @@ -1,5 +1,15 @@ - \ No newline at end of file + diff --git a/addon/components/modals/create-product-category.hbs b/addon/components/modals/create-product-category.hbs index 59b2b402..ffcb274e 100644 --- a/addon/components/modals/create-product-category.hbs +++ b/addon/components/modals/create-product-category.hbs @@ -1,5 +1,5 @@ diff --git a/addon/components/storefront/product/category-form.hbs b/addon/components/storefront/product/category-form.hbs index dd2932df..8de211ce 100644 --- a/addon/components/storefront/product/category-form.hbs +++ b/addon/components/storefront/product/category-form.hbs @@ -8,6 +8,19 @@ @value={{@category.description}} @helpText={{t "storefront.component.modals.create-product-category.description-text"}} /> +{{#if this.hasParentPicker}} + + diff --git a/addon/components/storefront/product/category-tree-select.js b/addon/components/storefront/product/category-tree-select.js new file mode 100644 index 00000000..ef598141 --- /dev/null +++ b/addon/components/storefront/product/category-tree-select.js @@ -0,0 +1,24 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { flattenCategoryTree } from '../../../utils/category-tree'; + +/** + * Picks a category from a store's tree, each option indented and named by its path + * ("Drinks › Coffee › Cold brew"). + */ +export default class StorefrontProductCategoryTreeSelectComponent extends Component { + get rows() { + return flattenCategoryTree(this.args.categories); + } + + get options() { + const indent = '    '; + + return this.rows.map((row) => ({ value: row.category.id, label: `${indent.repeat(row.depth)}${row.path}` })); + } + + @action select(value) { + const row = this.rows.find((candidate) => candidate.category.id === value); + this.args.onSelect?.(row?.category ?? null); + } +} diff --git a/addon/components/storefront/product/form.hbs b/addon/components/storefront/product/form.hbs index 2bc38d33..1ce3f389 100644 --- a/addon/components/storefront/product/form.hbs +++ b/addon/components/storefront/product/form.hbs @@ -172,7 +172,7 @@ @onChangeId={{fn (mut @product.category_uuid)}} as |model| > - {{model.name}} + {{this.categoryLabel model}}
diff --git a/addon/components/storefront/product/form.js b/addon/components/storefront/product/form.js index 3cf47199..8f758fd3 100644 --- a/addon/components/storefront/product/form.js +++ b/addon/components/storefront/product/form.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action, get } from '@ember/object'; import { inject as service } from '@ember/service'; +import { categoryPath } from '../../../utils/category-tree'; /** Which section of the page holds each field an issue can point at. */ const SECTION_FOR_FIELD = { name: 'product', price: 'pricing', sale_price: 'pricing', category: 'organization', media: 'media' }; @@ -33,6 +34,15 @@ function number(value) { */ export default class StorefrontProductFormComponent extends Component { @service intl; + @service store; + + /** A category option's place in the store's tree: "Drinks › Coffee › Cold brew". */ + categoryLabel = (category) => { + const ownerId = get(category, 'owner_uuid'); + const categories = this.store.peekAll('category').filter((record) => get(record, 'for') === 'storefront_product' && get(record, 'owner_uuid') === ownerId); + + return categoryPath(categories, category); + }; @tracked moreOpen = false; /** The language whose fields are on screen in the translations section. */ @tracked translationLanguage = null; diff --git a/addon/controllers/catalogs/index/edit.js b/addon/controllers/catalogs/index/edit.js index 09ce53dd..706a25e6 100644 --- a/addon/controllers/catalogs/index/edit.js +++ b/addon/controllers/catalogs/index/edit.js @@ -3,6 +3,13 @@ import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { task } from 'ember-concurrency'; +import { flattenCategoryTree, MAX_CATEGORY_DEPTH } from '../../../utils/category-tree'; + +/** A catalog category's key: its id once saved, a client key until then. */ +const keyOf = (category) => category.id ?? category.key ?? null; +/** The key of the category it sits under: a saved parent's id, or a new parent's key. */ +const parentKeyOf = (category) => category.parent_uuid ?? category.parent_key ?? null; +const newKey = () => `new-${Math.random().toString(36).slice(2, 10)}`; const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; @@ -36,17 +43,69 @@ export default class CatalogsIndexEditController extends Controller { return this.catalog?.categories?.toArray?.() ?? Array.from(this.catalog?.categories ?? []); } - get categoryRows() { + /** The menu in tree order: Lunch, Lunch › Tacos, Lunch › Tacos › Vegetarian, Drinks. */ + get categoryTree() { this.revision; - return this.categories.map((category, index) => ({ - category, - index, - count: category.products?.length ?? 0, - isSelected: category === this.selectedCategory, - isFirst: index === 0, - isLast: index === this.categories.length - 1, - })); + return flattenCategoryTree(this.categories, { idOf: keyOf, parentOf: parentKeyOf }); + } + + get categoryRows() { + const rows = this.categoryTree; + + return rows.map((row, index) => { + const siblings = rows.filter((candidate) => candidate.parentId === row.parentId); + const position = siblings.indexOf(row); + const previous = siblings[position - 1]; + const height = this.levelsUnder(rows, index); + + return { + category: row.category, + index, + depth: row.depth, + path: row.path, + count: row.category.products?.length ?? 0, + isSelected: row.category === this.selectedCategory, + isFirst: position === 0, + isLast: position === siblings.length - 1, + // Under the category above it, if that keeps the branch within three levels. + canIndent: Boolean(previous) && previous.depth + 1 + height < MAX_CATEGORY_DEPTH, + canOutdent: row.depth > 0, + canAddSubcategory: row.depth < MAX_CATEGORY_DEPTH - 1, + }; + }); + } + + /** How many levels sit under the row at `index` in tree order. */ + levelsUnder(rows, index) { + let deepest = rows[index].depth; + for (let next = index + 1; next < rows.length && rows[next].depth > rows[index].depth; next++) { + deepest = Math.max(deepest, rows[next].depth); + } + + return deepest - rows[index].depth; + } + + categoryByKey(key) { + return key ? (this.categories.find((category) => keyOf(category) === key) ?? null) : null; + } + + /** Place a category under another (or at the top): a saved parent by id, a new one by its key. */ + setParent(category, parent) { + if (parent && !keyOf(parent)) { + parent.set('key', newKey()); + } + + category.setProperties({ + parent_uuid: parent?.id ?? null, + parent_key: parent && !parent.id ? parent.key : null, + }); + } + + /** Number the categories in tree order, which keeps each one's place among its siblings. */ + renumber() { + flattenCategoryTree(this.categories, { idOf: keyOf, parentOf: parentKeyOf }).forEach((row, position) => row.category.set('order', position)); + this.touch(); } get selectedProducts() { @@ -223,6 +282,8 @@ export default class CatalogsIndexEditController extends Controller { categories: this.categories.map((category) => ({ id: category.id, name: category.name, + parent: parentKeyOf(category), + order: Number(category.order ?? 0), products: (category.products?.toArray?.() ?? Array.from(category.products ?? [])).map((product) => product.id), overrides: this.overridesFor(category), })), @@ -292,7 +353,7 @@ export default class CatalogsIndexEditController extends Controller { return; } - const category = this.store.createRecord('catalog-category', { name, for: 'storefront_catalog', order: this.categories.length }); + const category = this.store.createRecord('catalog-category', { name, for: 'storefront_catalog', order: this.categories.length, key: newKey() }); this.catalog.categories.pushObject(category); this.newCategoryName = ''; this.selectedCategory = category; @@ -323,19 +384,68 @@ export default class CatalogsIndexEditController extends Controller { this.renamingCategory = null; } + /** Up or down among the categories it shares a parent with. */ @action moveCategory(category, direction) { - const list = this.catalog.categories; - const index = list.indexOf(category); + const tree = this.categoryTree; + const parentId = tree.find((row) => row.category === category)?.parentId ?? null; + const siblings = tree.filter((row) => row.parentId === parentId).map((row) => row.category); + const index = siblings.indexOf(category); const target = index + direction; - if (index < 0 || target < 0 || target >= list.length) { + if (index < 0 || target < 0 || target >= siblings.length) { return; } - list.removeObject(category); - list.insertAt(target, category); - list.forEach((item, position) => item.set('order', position)); - this.touch(); + const orders = siblings.map((sibling) => Number(sibling.order ?? 0)); + siblings.splice(index, 1); + siblings.splice(target, 0, category); + siblings.forEach((sibling, position) => sibling.set('order', orders[position])); + this.renumber(); + } + + /** Under the category above it, as its last subcategory. */ + @action indentCategory(category) { + const row = this.categoryRows.find((candidate) => candidate.category === category); + if (!row?.canIndent) { + return; + } + + const tree = this.categoryTree; + const siblings = tree.filter((candidate) => candidate.parentId === tree[row.index].parentId); + const previous = siblings[siblings.findIndex((candidate) => candidate.category === category) - 1].category; + this.setParent(category, previous); + category.set('order', Number.MAX_SAFE_INTEGER); + this.renumber(); + } + + /** Up a level, right after the category it was under. */ + @action outdentCategory(category) { + const parent = this.categoryByKey(parentKeyOf(category)); + if (!parent) { + return; + } + + this.setParent(category, this.categoryByKey(parentKeyOf(parent))); + category.set('order', Number(parent.order ?? 0) + 0.5); + this.renumber(); + } + + @action addSubcategory(parent) { + if (!this.categoryRows.find((row) => row.category === parent)?.canAddSubcategory) { + return; + } + + const category = this.store.createRecord('catalog-category', { + name: this.intl.t('storefront.catalogs.editor.new-subcategory-name'), + for: 'storefront_catalog', + key: newKey(), + order: Number.MAX_SAFE_INTEGER, + }); + this.setParent(category, parent); + this.catalog.categories.pushObject(category); + this.renumber(); + this.selectedCategory = category; + this.startRename(category); } @action removeCategory(category) { @@ -345,7 +455,16 @@ export default class CatalogsIndexEditController extends Controller { acceptButtonText: this.intl.t('common.delete'), acceptButtonScheme: 'danger', confirm: (modal) => { + // Its subcategories move up to where it was. + const parent = this.categoryByKey(parentKeyOf(category)); + this.categories + .filter((child) => parentKeyOf(child) === keyOf(category)) + .forEach((child) => { + this.setParent(child, parent); + child.set('order', Number(category.order ?? 0) + Number(child.order ?? 0) / 1000); + }); this.catalog.categories.removeObject(category); + this.renumber(); if (this.selectedCategory === category) { this.selectedCategory = this.categories[0] ?? null; @@ -494,8 +613,16 @@ export default class CatalogsIndexEditController extends Controller { status: 'draft', }); + // Clones keep the tree: each finds its parent's clone by key. + const clones = new Map( + this.categories.map((category) => [ + keyOf(category), + this.store.createRecord('catalog-category', { name: category.name, for: 'storefront_catalog', order: category.order, key: newKey() }), + ]) + ); this.categories.forEach((category) => { - const clone = this.store.createRecord('catalog-category', { name: category.name, for: 'storefront_catalog', order: category.order }); + const clone = clones.get(keyOf(category)); + clone.set('parent_key', clones.get(parentKeyOf(category))?.key ?? null); (category.products?.toArray?.() ?? []).forEach((product) => clone.products.pushObject(product)); copy.categories.pushObject(clone); }); diff --git a/addon/controllers/products/index.js b/addon/controllers/products/index.js index 1e5de133..e5ad7822 100644 --- a/addon/controllers/products/index.js +++ b/addon/controllers/products/index.js @@ -7,6 +7,7 @@ import { alias } from '@ember/object/computed'; import { dasherize } from '@ember/string'; import { isBlank } from '@ember/utils'; import { timeout, task } from 'ember-concurrency'; +import { categoryAndDescendantIds, categoryPath } from '../../utils/category-tree'; export default class ProductsIndexController extends BaseController { @controller('products.index.index') productsIndexIndexController; @@ -55,8 +56,15 @@ export default class ProductsIndexController extends BaseController { return this.summary?.by_category ?? {}; } - /** The header's meta line: products and categories in this store. */ + /** The header's meta line: products and categories in this store, or the open category's place and products. */ get headerMeta() { + if (this.category) { + const ids = categoryAndDescendantIds(this.productCategories, this.category); + const products = [...ids].reduce((sum, id) => sum + Number(this.categoryCounts[id] ?? 0), 0); + + return this.intl.t('storefront.products.index.category-meta', { path: categoryPath(this.productCategories, this.category), products }); + } + const categories = this.model?.length ?? Object.keys(this.categoryCounts).length; // Before the first list response there is no product count to claim, only the categories. @@ -396,21 +404,33 @@ export default class ProductsIndexController extends BaseController { this.transitionToRoute('products.index.category', category.slug); } - @action createNewProductCategory() { + /** A subcategory: the new category starts under the given one. */ + @action createProductSubcategory(parent) { + return this.createNewProductCategory(parent); + } + + @action createNewProductCategory(parent = null) { + const parentCategory = parent && typeof parent === 'object' && parent.id && !parent.target ? parent : null; const category = this.store.createRecord('category', { company_uuid: this.currentUser.companyId, owner_uuid: this.currentUser.getOption('activeStorefront'), owner_type: 'storefront:store', for: 'storefront_product', + parent_uuid: parentCategory?.id ?? null, + parent: parentCategory, }); this.modalsManager.show('modals/create-product-category', { - title: this.intl.t('storefront.products.index.create-new-product-category'), + title: parentCategory + ? this.intl.t('storefront.products.index.create-new-product-subcategory', { parent: parentCategory.name }) + : this.intl.t('storefront.products.index.create-new-product-category'), acceptButtonIcon: 'check', acceptButtonIconPrefix: 'fas', declineButtonIcon: 'times', declineButtonIconPrefix: 'fas', category, + // The store's categories, for the parent picker. + categories: this.productCategories, uploadNewPhoto: (file) => { this.fetch.uploadFile.perform( file, @@ -432,14 +452,26 @@ export default class ProductsIndexController extends BaseController { confirm: (modal) => { modal.startLoading(); - return category.save().then(() => { - this.notifications.success(this.intl.t('storefront.products.index.product-category-created-success')); - return this.hostRouter.refresh(); - }); + return category + .save() + .then(() => { + this.notifications.success(this.intl.t('storefront.products.index.product-category-created-success')); + return this.hostRouter.refresh(); + }) + .catch((error) => { + // A parent the server refuses (too deep, another store's) is said, and the form stays open. + modal.stopLoading(); + this.notifications.serverError(error); + }); }, }); } + /** The store's product categories, saved ones only. */ + get productCategories() { + return (this.model?.toArray?.() ?? Array.from(this.model ?? [])).filter((category) => category && !category.isNew && !category.isDeleted); + } + @action importProducts() { const checkQueue = () => { const uploadQueue = this.modalsManager.getOption('uploadQueue'); diff --git a/addon/controllers/products/index/category.js b/addon/controllers/products/index/category.js index a7ea68e4..15b9a5a7 100644 --- a/addon/controllers/products/index/category.js +++ b/addon/controllers/products/index/category.js @@ -10,6 +10,7 @@ import { timeout, task } from 'ember-concurrency'; export default class ProductsIndexCategoryController extends BaseController { @controller('products.index') productsIndexController; @service intl; + @service store; @service modalsManager; @service fetch; @service hostRouter; @@ -130,6 +131,10 @@ export default class ProductsIndexCategoryController extends BaseController { declineButtonIcon: 'times', declineButtonIconPrefix: 'fas', category, + // The store's other categories, for the parent picker. + categories: this.store + .peekAll('category') + .filter((record) => record.for === 'storefront_product' && record.owner_uuid === category.owner_uuid && !record.isNew && !record.isDeleted), uploadNewPhoto: (file) => { this.fetch.uploadFile.perform( file, @@ -151,9 +156,15 @@ export default class ProductsIndexCategoryController extends BaseController { confirm: (modal) => { modal.startLoading(); - return category.save().then(() => { - this.notifications.success(this.intl.t('storefront.products.index.category.category-changes-saved-success')); - }); + return category + .save() + .then(() => { + this.notifications.success(this.intl.t('storefront.products.index.category.category-changes-saved-success')); + }) + .catch((error) => { + modal.stopLoading(); + this.notifications.serverError(error); + }); }, }); } diff --git a/addon/models/catalog-category.js b/addon/models/catalog-category.js index b6e19968..e7b7fda8 100644 --- a/addon/models/catalog-category.js +++ b/addon/models/catalog-category.js @@ -5,4 +5,7 @@ export default class CatalogCategoryModel extends CategoryModel { @hasMany('products', { async: false, inverse: 'catalogCategories' }) products; /** Per-catalog overrides keyed by product uuid: `{ price, is_available }`, each null to inherit from the store. */ @attr('raw') product_overrides; + /** A new category's key until it is saved, and the key of a new parent it sits under: the catalog save places it by these. */ + @attr('string') key; + @attr('string') parent_key; } diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index d6f0da31..406e0931 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -4800,6 +4800,14 @@ body[data-theme='dark'] .storefront-catalog-category { border-color: #374151; } +.storefront-catalog-category--depth-1 { + padding-left: 1.5rem; +} + +.storefront-catalog-category--depth-2 { + padding-left: 2.5rem; +} + .storefront-catalog-category.is-selected { background-color: #dbeafe; } @@ -5614,6 +5622,38 @@ body[data-theme='dark'] .storefront-product-category__caret:hover { color: #e5e7eb; } +.storefront-product-category__add { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + margin-left: auto; + border-radius: 0.25rem; + color: #6b7280; + visibility: hidden; +} + +.storefront-product-category:hover .storefront-product-category__add, +.storefront-product-category:focus-within .storefront-product-category__add { + visibility: visible; +} + +.storefront-product-category__add:hover { + background-color: #e5e7eb; + color: #374151; +} + +body[data-theme='dark'] .storefront-product-category__add:hover { + background-color: #374151; + color: #e5e7eb; +} + +.storefront-product-category__add + .storefront-product-category__count { + margin-left: 0.25rem; +} + .storefront-product-category__count { flex: 0 0 auto; margin-left: auto; diff --git a/addon/templates/catalogs/index/edit.hbs b/addon/templates/catalogs/index/edit.hbs index 4455b542..a546e14a 100644 --- a/addon/templates/catalogs/index/edit.hbs +++ b/addon/templates/catalogs/index/edit.hbs @@ -28,7 +28,12 @@