- {{#if (eq this.renamingCategory row.category)}}
-
- {{else}}
-
- {{row.category.name}}
- {{row.count}}
-
-
-
-
-
-
-
- {{/if}}
+
+
+ {{#if row.category.icon_file_uuid}}
+
+ {{/if}}
+ {{row.category.name}}
+ {{row.count}}
+
+
+
+
+
+
+ {{#if row.canAddSubcategory}}
+
+ {{/if}}
+
+
+
{{else}}
{{t "storefront.catalogs.card.no-categories"}}
diff --git a/addon/templates/products/index.hbs b/addon/templates/products/index.hbs
index a2fdf09f..3aef632d 100644
--- a/addon/templates/products/index.hbs
+++ b/addon/templates/products/index.hbs
@@ -22,6 +22,7 @@
@categories={{@model}}
@activeCategory={{this.category}}
@onCreate={{this.createNewProductCategory}}
+ @onCreateSubcategory={{this.createProductSubcategory}}
@onViewAll={{this.viewAllProducts}}
@onSelect={{this.switchCategory}}
@counts={{this.categoryCounts}}
diff --git a/addon/utils/category-tree.js b/addon/utils/category-tree.js
new file mode 100644
index 00000000..c4749ba1
--- /dev/null
+++ b/addon/utils/category-tree.js
@@ -0,0 +1,89 @@
+import { get } from '@ember/object';
+
+/** Product and catalog categories nest at most three levels: Drinks › Coffee › Cold brew. */
+export const MAX_CATEGORY_DEPTH = 3;
+
+const toArray = (categories) => categories?.toArray?.() ?? Array.from(categories ?? []);
+const idOf = (category) => get(category, 'id') ?? get(category, 'uuid') ?? null;
+const parentOf = (category) => get(category, 'parent_uuid') ?? null;
+
+/**
+ * Categories in tree order, parents before their children, each with its depth (0 at the top)
+ * and its path ("Drinks › Coffee"). A category whose parent is not in the list is a root.
+ *
+ * @param {Array} categories
+ * @returns {Array<{ category, depth: number, path: string, parentId: string|null }>}
+ */
+export function flattenCategoryTree(categories, { idOf: idFor = idOf, parentOf: parentFor = parentOf, order = (a, b) => Number(get(a, 'order') ?? 0) - Number(get(b, 'order') ?? 0) } = {}) {
+ const all = toArray(categories);
+ const ids = new Set(all.map(idFor));
+ const childrenOf = (parentId) => all.filter((category) => parentFor(category) === parentId).sort(order);
+ const rows = [];
+ const seen = new Set();
+ const walk = (category, depth, trail) => {
+ const id = idFor(category);
+ if (seen.has(id)) {
+ return;
+ }
+ seen.add(id);
+ const path = [...trail, get(category, 'name')].filter(Boolean);
+ rows.push({ category, depth, path: path.join(' › '), parentId: ids.has(parentFor(category)) ? parentFor(category) : null });
+ childrenOf(id).forEach((child) => walk(child, depth + 1, path));
+ };
+
+ all.filter((category) => !parentFor(category) || !ids.has(parentFor(category)))
+ .sort(order)
+ .forEach((root) => walk(root, 0, []));
+
+ return rows;
+}
+
+/** The ids of a category and every category under it. */
+export function categoryAndDescendantIds(categories, category) {
+ const all = toArray(categories);
+ const ids = new Set([idOf(category)]);
+ let level = [idOf(category)];
+
+ while (level.length) {
+ level = all.filter((candidate) => level.includes(parentOf(candidate)) && !ids.has(idOf(candidate))).map(idOf);
+ level.forEach((id) => ids.add(id));
+ }
+
+ return ids;
+}
+
+/** How many levels sit under a category: 0 for one without subcategories. */
+export function categoryHeight(categories, category) {
+ const all = toArray(categories);
+ let height = 0;
+ let level = [idOf(category)];
+ const seen = new Set(level);
+
+ for (;;) {
+ level = all.filter((candidate) => level.includes(parentOf(candidate)) && !seen.has(idOf(candidate))).map(idOf);
+ if (!level.length) {
+ return height;
+ }
+ level.forEach((id) => seen.add(id));
+ height++;
+ }
+}
+
+/**
+ * The categories a category can be placed under: not itself or anything under it, and none that
+ * would put it, or the levels under it, past three levels deep.
+ *
+ * @returns {Array<{ category, depth: number, path: string }>}
+ */
+export function parentCategoryOptions(categories, category = null) {
+ const rows = flattenCategoryTree(categories);
+ const excluded = category && idOf(category) ? categoryAndDescendantIds(categories, category) : new Set();
+ const height = category && idOf(category) ? categoryHeight(categories, category) : 0;
+
+ return rows.filter((row) => !excluded.has(idOf(row.category)) && row.depth + 1 + 1 + height <= MAX_CATEGORY_DEPTH);
+}
+
+/** A category's place in the tree: "Drinks › Coffee › Cold brew". */
+export function categoryPath(categories, category) {
+ return flattenCategoryTree(categories).find((row) => idOf(row.category) === idOf(category))?.path ?? get(category, 'name') ?? '';
+}
diff --git a/app/components/storefront/product/category-tree-select.js b/app/components/storefront/product/category-tree-select.js
new file mode 100644
index 00000000..845a068a
--- /dev/null
+++ b/app/components/storefront/product/category-tree-select.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/category-tree-select';
diff --git a/composer.json b/composer.json
index b1a9474a..c8a387c0 100644
--- a/composer.json
+++ b/composer.json
@@ -22,7 +22,7 @@
],
"require": {
"php": "^8.0",
- "fleetbase/core-api": "^1.6.70",
+ "fleetbase/core-api": "^1.6.71",
"fleetbase/fleetops-api": "*",
"geocoder-php/google-maps-places-provider": "^1.4",
"laravel-notification-channels/apn": "^5.0",
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..45cb85c3 100644
--- a/server/src/Http/Resources/CatalogCategory.php
+++ b/server/src/Http/Resources/CatalogCategory.php
@@ -23,10 +23,15 @@ 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,
'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/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..fc9b966a 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');
@@ -198,6 +211,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,20 +222,35 @@ 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
$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/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/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/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/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();
+});
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/add-store-to-category-test.js b/tests/integration/components/modals/add-store-to-category-test.js
index 952715c4..3c1f666b 100644
--- a/tests/integration/components/modals/add-store-to-category-test.js
+++ b/tests/integration/components/modals/add-store-to-category-test.js
@@ -1,6 +1,6 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
-import { render } from '@ember/test-helpers';
+import { render, select } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | modals/add-store-to-category', function (hooks) {
@@ -19,4 +19,19 @@ module('Integration | Component | modals/add-store-to-category', function (hooks
await render(hbs` `);
assert.dom(this.element).exists();
});
+
+ test("moving products offers the store's categories in tree order", async function (assert) {
+ const picked = [];
+ const drinks = { id: 'drinks', name: 'Drinks', parent_uuid: null };
+ const coffee = { id: 'coffee', name: 'Coffee', parent_uuid: 'drinks' };
+ this.set('options', { categories: [coffee, drinks], category: null, setCategory: (category) => picked.push(category) });
+
+ await render(hbs` `);
+
+ const labels = [...this.element.querySelectorAll('[data-test-category-tree-select] option:not([disabled])')].map((option) => option.textContent.trim());
+ assert.deepEqual(labels, ['Drinks', 'Drinks › Coffee']);
+
+ await select('[data-test-category-tree-select]', 'coffee');
+ assert.deepEqual(picked, [coffee]);
+ });
});
diff --git a/tests/integration/components/modals/create-product-category-test.js b/tests/integration/components/modals/create-product-category-test.js
index 491bbbd5..1c00176c 100644
--- a/tests/integration/components/modals/create-product-category-test.js
+++ b/tests/integration/components/modals/create-product-category-test.js
@@ -1,10 +1,14 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
-import { render } from '@ember/test-helpers';
+import { fillIn, render, select, settled } from '@ember/test-helpers';
+import { run } from '@ember/runloop';
+import EmberObject from '@ember/object';
+import { setupIntl } from 'ember-intl/test-support';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | modals/create-product-category', function (hooks) {
setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
test('it renders the category form', async function (assert) {
this.set('options', {
@@ -21,4 +25,47 @@ 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("given the store's categories, the form offers the parents a category can go under", async function (assert) {
+ // Drinks › Coffee › Cold brew, and Food; Coffee is being edited.
+ const drinks = { id: 'drinks', name: 'Drinks', parent_uuid: null };
+ const coffee = EmberObject.create({ id: 'coffee', name: 'Coffee', parent_uuid: 'drinks' });
+ const coldBrew = { id: 'cold_brew', name: 'Cold brew', parent_uuid: 'coffee' };
+ const food = { id: 'food', name: 'Food', parent_uuid: null };
+ this.set('options', { category: coffee, categories: [drinks, coffee, coldBrew, food], uploadNewPhoto() {} });
+
+ await render(hbs` `);
+
+ const labels = [...this.element.querySelectorAll('[data-test-category-parent] option')].map((option) => option.textContent.trim());
+ // Not itself, not Cold brew under it, and nothing that would put Cold brew past the third level.
+ assert.deepEqual(labels, ['None (top level)', 'Drinks', 'Food']);
+ assert.dom('[data-test-category-parent]').hasValue('drinks');
+
+ await select('[data-test-category-parent]', 'food');
+ assert.strictEqual(coffee.parent_uuid, 'food');
+ assert.strictEqual(coffee.parent, food, 'the parent is set with its id, as the serializer writes it');
+
+ await select('[data-test-category-parent]', '');
+ assert.strictEqual(coffee.parent_uuid, null, 'back to the top level');
+ });
+
+ 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/integration/components/storefront/product/category-sidebar-test.js b/tests/integration/components/storefront/product/category-sidebar-test.js
index ff96e01c..702d71f9 100644
--- a/tests/integration/components/storefront/product/category-sidebar-test.js
+++ b/tests/integration/components/storefront/product/category-sidebar-test.js
@@ -58,4 +58,36 @@ module('Integration | Component | storefront/product/category-sidebar', function
await click('[data-test-storefront-product-category="cat_2"]');
});
+
+ test('categories nest under their parents, counts roll up, and a category above the third level takes a subcategory', async function (assert) {
+ const drinks = { id: 'drinks', name: 'Drinks', parent_uuid: null };
+ const coffee = { id: 'coffee', name: 'Coffee', parent_uuid: 'drinks' };
+ const coldBrew = { id: 'cold_brew', name: 'Cold brew', parent_uuid: 'coffee' };
+ const created = [];
+
+ this.setProperties({
+ categories: [coldBrew, coffee, drinks],
+ counts: { drinks: 1, coffee: 2, cold_brew: 3 },
+ createSubcategory: (category) => created.push(category.id),
+ noop() {},
+ });
+
+ await render(hbs` `);
+
+ assert.dom('[data-test-storefront-product-category="cold_brew"]').hasClass('storefront-product-category--depth-2');
+ assert.dom('[data-test-storefront-product-category="drinks"] .storefront-product-category__count').hasText('6', 'a parent counts everything under it');
+ assert.dom('[data-test-storefront-product-subcategory="coffee"]').exists('a second-level category takes a subcategory');
+ assert.dom('[data-test-storefront-product-subcategory="cold_brew"]').doesNotExist('a third-level category takes none');
+
+ await click('[data-test-storefront-product-subcategory="drinks"]');
+ assert.deepEqual(created, ['drinks']);
+ });
});
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/tests/unit/controllers/catalogs/index/edit-test.js b/tests/unit/controllers/catalogs/index/edit-test.js
index ad3dad44..b197c9ff 100644
--- a/tests/unit/controllers/catalogs/index/edit-test.js
+++ b/tests/unit/controllers/catalogs/index/edit-test.js
@@ -1,5 +1,7 @@
import { module, test } from 'qunit';
import { setupTest } from 'dummy/tests/helpers';
+import EmberObject from '@ember/object';
+import { A } from '@ember/array';
module('Unit | Controller | catalogs/index/edit', function (hooks) {
setupTest(hooks);
@@ -42,4 +44,114 @@ module('Unit | Controller | catalogs/index/edit', function (hooks) {
assert.deepEqual(toasts, ['nope']);
});
+
+ /** A catalog with Lunch › Tacos › Vegetarian and Drinks, saved, as the editor holds it. */
+ function nestedMenu(controller) {
+ const category = (attributes) => EmberObject.create({ products: [], parent_uuid: null, parent_key: null, key: null, ...attributes });
+ const lunch = category({ id: 'lunch', name: 'Lunch', order: 0 });
+ const tacos = category({ id: 'tacos', name: 'Tacos', parent_uuid: 'lunch', order: 1 });
+ const veggie = category({ id: 'veggie', name: 'Vegetarian', parent_uuid: 'tacos', order: 2 });
+ const drinks = category({ id: 'drinks', name: 'Drinks', order: 3 });
+
+ controller.catalog = { categories: A([lunch, tacos, veggie, drinks]) };
+ controller.intl = { t: (key) => key };
+ controller.store = { createRecord: (type, attributes) => category({ id: null, ...attributes }) };
+ controller.modalsManager = {
+ shown: [],
+ confirm: ({ confirm }) => confirm({ done() {} }),
+ show(name, options) {
+ this.shown.push({ name, category: options.category });
+ },
+ };
+
+ return { lunch, tacos, veggie, drinks };
+ }
+
+ const shape = (controller) => controller.categoryRows.map((row) => `${'-'.repeat(row.depth)}${row.category.name}`);
+
+ test('the menu reads as a tree, and only moves that keep it three levels deep are offered', function (assert) {
+ const controller = this.owner.lookup('controller:catalogs/index/edit');
+ nestedMenu(controller);
+ const rows = Object.fromEntries(controller.categoryRows.map((row) => [row.category.name, row]));
+
+ assert.deepEqual(shape(controller), ['Lunch', '-Tacos', '--Vegetarian', 'Drinks']);
+ assert.strictEqual(rows.Vegetarian.path, 'Lunch › Tacos › Vegetarian');
+ assert.true(rows.Drinks.canIndent, 'Drinks can go under Lunch, the category above it at its level');
+ assert.false(rows.Tacos.canIndent, 'Tacos is the first under Lunch: nothing above it to go under');
+ assert.false(rows.Vegetarian.canAddSubcategory, 'a third-level category takes no subcategories');
+ assert.true(rows.Tacos.canAddSubcategory);
+ assert.false(rows.Lunch.canOutdent);
+ assert.true(rows.Vegetarian.canOutdent);
+ assert.true(rows.Lunch.isFirst && rows.Drinks.isLast);
+ });
+
+ test('a category is indented under the one above, moved up a level, given a subcategory, and moved among its siblings', function (assert) {
+ const controller = this.owner.lookup('controller:catalogs/index/edit');
+ const { lunch, veggie, drinks } = nestedMenu(controller);
+
+ controller.outdentCategory(veggie);
+ assert.deepEqual(shape(controller), ['Lunch', '-Tacos', '-Vegetarian', 'Drinks'], 'Vegetarian sits right after Tacos, under Lunch');
+ assert.strictEqual(veggie.parent_uuid, 'lunch');
+
+ controller.indentCategory(drinks);
+ assert.deepEqual(shape(controller), ['Lunch', '-Tacos', '-Vegetarian', '-Drinks'], 'Drinks goes under Lunch, last');
+
+ controller.moveCategory(drinks, -1);
+ assert.deepEqual(shape(controller), ['Lunch', '-Tacos', '-Drinks', '-Vegetarian']);
+
+ controller.addSubcategory(drinks);
+ const added = controller.categories.find((category) => category.name === 'storefront.catalogs.editor.new-subcategory-name');
+ assert.deepEqual(shape(controller), ['Lunch', '-Tacos', '-Drinks', '--storefront.catalogs.editor.new-subcategory-name', '-Vegetarian']);
+ assert.strictEqual(added.parent_uuid, 'drinks', 'a saved parent is found by its id');
+ assert.ok(added.key, 'a new category has a key until it is saved');
+ assert.deepEqual(controller.modalsManager.shown.at(-1), { name: 'modals/create-product-category', category: added }, 'the new subcategory opens in the category modal to be named');
+
+ // Already three levels deep: no subcategory.
+ const count = controller.categories.length;
+ controller.addSubcategory(added);
+ assert.strictEqual(controller.categories.length, count, 'a third-level category takes no subcategory');
+
+ // A subcategory of a new category finds its parent by key.
+ controller.newCategoryName = 'Breakfast';
+ controller.addCategory();
+ const breakfast = controller.categories.find((category) => category.name === 'Breakfast');
+ controller.addSubcategory(breakfast);
+ const child = controller.categories.find((category) => category.parent_key === breakfast.key);
+ assert.ok(child, 'placed under the unsaved category by its key');
+ assert.strictEqual(child.parent_uuid, null);
+ assert.ok(lunch.order < drinks.order, 'orders follow the tree');
+ });
+
+ test('removing a category moves its subcategories up to where it was', async function (assert) {
+ const controller = this.owner.lookup('controller:catalogs/index/edit');
+ const { tacos, veggie } = nestedMenu(controller);
+
+ await controller.removeCategory(tacos);
+
+ assert.deepEqual(shape(controller), ['Lunch', '-Vegetarian', 'Drinks']);
+ assert.strictEqual(veggie.parent_uuid, 'lunch');
+ });
+
+ test('a duplicated catalog keeps its menu tree', function (assert) {
+ const controller = this.owner.lookup('controller:catalogs/index/edit');
+ nestedMenu(controller);
+ let copy;
+ controller.store.createRecord = (type, attributes) => {
+ const record = EmberObject.create({ products: A([]), categories: A([]), parent_key: null, ...attributes });
+ if (type === 'catalog') {
+ record.save = () => Promise.resolve();
+ copy = record;
+ }
+ return record;
+ };
+ controller.notifications = { success() {}, serverError() {} };
+ controller.hostRouter = { transitionTo() {} };
+
+ controller.duplicate();
+ const clones = Object.fromEntries(copy.categories.map((category) => [category.name, category]));
+
+ assert.strictEqual(clones.Tacos.parent_key, clones.Lunch.key);
+ assert.strictEqual(clones.Vegetarian.parent_key, clones.Tacos.key);
+ assert.strictEqual(clones.Drinks.parent_key, null);
+ });
});
diff --git a/tests/unit/utils/category-tree-test.js b/tests/unit/utils/category-tree-test.js
new file mode 100644
index 00000000..2314f39b
--- /dev/null
+++ b/tests/unit/utils/category-tree-test.js
@@ -0,0 +1,68 @@
+import { module, test } from 'qunit';
+import { flattenCategoryTree, categoryAndDescendantIds, categoryHeight, parentCategoryOptions, categoryPath, MAX_CATEGORY_DEPTH } from '@fleetbase/storefront-engine/utils/category-tree';
+
+module('Unit | Utility | category-tree', function () {
+ // Drinks › Coffee › Cold brew, Drinks › Tea, Food
+ const drinks = { id: 'drinks', name: 'Drinks', parent_uuid: null, order: 0 };
+ const coffee = { id: 'coffee', name: 'Coffee', parent_uuid: 'drinks', order: 0 };
+ const coldBrew = { id: 'cold_brew', name: 'Cold brew', parent_uuid: 'coffee', order: 0 };
+ const tea = { id: 'tea', name: 'Tea', parent_uuid: 'drinks', order: 1 };
+ const food = { id: 'food', name: 'Food', parent_uuid: null, order: 1 };
+ const all = [food, coldBrew, tea, coffee, drinks];
+
+ test('categories come out in tree order with their depth and path', function (assert) {
+ assert.strictEqual(MAX_CATEGORY_DEPTH, 3);
+ assert.deepEqual(
+ flattenCategoryTree(all).map((row) => [row.category.id, row.depth, row.path, row.parentId]),
+ [
+ ['drinks', 0, 'Drinks', null],
+ ['coffee', 1, 'Drinks › Coffee', 'drinks'],
+ ['cold_brew', 2, 'Drinks › Coffee › Cold brew', 'coffee'],
+ ['tea', 1, 'Drinks › Tea', 'drinks'],
+ ['food', 0, 'Food', null],
+ ]
+ );
+ assert.deepEqual(flattenCategoryTree(null), [], 'nothing to flatten');
+ assert.deepEqual(
+ flattenCategoryTree([{ id: 'orphan', name: 'Orphan', parent_uuid: 'gone' }]).map((row) => [row.category.id, row.depth, row.parentId]),
+ [['orphan', 0, null]],
+ 'a category whose parent is not listed is at the top'
+ );
+ });
+
+ test('a category knows what is under it and how many levels', function (assert) {
+ assert.deepEqual([...categoryAndDescendantIds(all, drinks)].sort(), ['coffee', 'cold_brew', 'drinks', 'tea']);
+ assert.strictEqual(categoryHeight(all, drinks), 2);
+ assert.strictEqual(categoryHeight(all, coffee), 1);
+ assert.strictEqual(categoryHeight(all, food), 0);
+ assert.strictEqual(categoryPath(all, coldBrew), 'Drinks › Coffee › Cold brew');
+ assert.strictEqual(categoryPath([], { name: 'Loose' }), 'Loose', 'a category outside the list is named as is');
+ });
+
+ test('a category can be placed under anything but itself, its subcategories, or too deep', function (assert) {
+ const ids = (rows) => rows.map((row) => row.category.id);
+
+ // A new category can go under any category above the third level.
+ assert.deepEqual(ids(parentCategoryOptions(all)), ['drinks', 'coffee', 'tea', 'food']);
+ // Food (nothing under it) can go under Coffee, but not under Cold brew (a fourth level).
+ assert.deepEqual(ids(parentCategoryOptions(all, food)), ['drinks', 'coffee', 'tea']);
+ // Coffee (Cold brew under it) only goes under a top-level category, and never under itself.
+ assert.deepEqual(ids(parentCategoryOptions(all, coffee)), ['drinks', 'food']);
+ // Drinks has two levels under it: it can only stay at the top.
+ assert.deepEqual(ids(parentCategoryOptions(all, drinks)), []);
+ });
+
+ test("a tree can be read by other keys, as a catalog's unsaved categories are", function (assert) {
+ const lunch = { key: 'new-lunch', name: 'Lunch', order: 0 };
+ const tacos = { key: 'new-tacos', parent_key: 'new-lunch', name: 'Tacos', order: 1 };
+ const rows = flattenCategoryTree([tacos, lunch], { idOf: (category) => category.key, parentOf: (category) => category.parent_key ?? null });
+
+ assert.deepEqual(
+ rows.map((row) => [row.category.key, row.depth, row.path]),
+ [
+ ['new-lunch', 0, 'Lunch'],
+ ['new-tacos', 1, 'Lunch › Tacos'],
+ ]
+ );
+ });
+});
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index b992fa8c..12a1375a 100644
--- a/translations/en-us.yaml
+++ b/translations/en-us.yaml
@@ -211,9 +211,12 @@ 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
+ parent: Parent category
+ parent-text: Categories nest up to three levels, like Drinks › Coffee › Cold brew.
+ top-level: None (top level)
create-store:
name: Storefront name
name-text: Enter the name of your store
@@ -453,7 +456,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,12 +480,15 @@ storefront:
served-by: Served by
details: Details
new-category: New category
- rename: Rename
move-up: Move up
move-down: Move down
+ indent: Move under the category above
+ outdent: Move up a level
+ add-subcategory: Add subcategory
+ new-subcategory-name: New subcategory
categories-note: The order here is the order the app shows. A category with no products is hidden in the app.
remove-category-title: "Remove the category {name}?"
- remove-category-body: Its products stay in the store; they just leave this catalog.
+ remove-category-body: Its products stay in the store; they just leave this catalog. Its subcategories move up a level.
add-products: Add products
search-products: Search the store's products
picker-hint: "{count, plural, =0 {Products already in this catalog are hidden.} one {# selected} other {# selected}}"
@@ -1738,6 +1747,7 @@ storefront:
categories: Categories
categories-help: Organise products for browsing. Click a caret to fold a category.
new-category: New category
+ new-subcategory: New subcategory
all-products: All products
no-categories: No categories yet
smart-groups: Smart groups
@@ -1969,6 +1979,8 @@ storefront:
manage-addons-accept-button: Save Changes
done: Done
create-new-product-category: Create a new product category
+ create-new-product-subcategory: "New category under {parent}"
+ category-meta: "{path} · {products, plural, =0 {no products} one {# product} other {# products}}"
product-category-created-success: New product category created.
import-products-via-spreadsheets: Import products via spreadsheets
start-upload: Start Upload