diff --git a/addon/components/modals/add-store-to-category.hbs b/addon/components/modals/add-store-to-category.hbs index af0634b8..d198f233 100644 --- a/addon/components/modals/add-store-to-category.hbs +++ b/addon/components/modals/add-store-to-category.hbs @@ -1,5 +1,15 @@ - \ No newline at end of file + diff --git a/addon/components/modals/create-product-category.hbs b/addon/components/modals/create-product-category.hbs index 59b2b402..ffcb274e 100644 --- a/addon/components/modals/create-product-category.hbs +++ b/addon/components/modals/create-product-category.hbs @@ -1,5 +1,5 @@ diff --git a/addon/components/storefront/product/category-form.hbs b/addon/components/storefront/product/category-form.hbs index dd2932df..8de211ce 100644 --- a/addon/components/storefront/product/category-form.hbs +++ b/addon/components/storefront/product/category-form.hbs @@ -8,6 +8,19 @@ @value={{@category.description}} @helpText={{t "storefront.component.modals.create-product-category.description-text"}} /> +{{#if this.hasParentPicker}} + + diff --git a/addon/components/storefront/product/category-tree-select.js b/addon/components/storefront/product/category-tree-select.js new file mode 100644 index 00000000..ef598141 --- /dev/null +++ b/addon/components/storefront/product/category-tree-select.js @@ -0,0 +1,24 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { flattenCategoryTree } from '../../../utils/category-tree'; + +/** + * Picks a category from a store's tree, each option indented and named by its path + * ("Drinks › Coffee › Cold brew"). + */ +export default class StorefrontProductCategoryTreeSelectComponent extends Component { + get rows() { + return flattenCategoryTree(this.args.categories); + } + + get options() { + const indent = '    '; + + return this.rows.map((row) => ({ value: row.category.id, label: `${indent.repeat(row.depth)}${row.path}` })); + } + + @action select(value) { + const row = this.rows.find((candidate) => candidate.category.id === value); + this.args.onSelect?.(row?.category ?? null); + } +} diff --git a/addon/components/storefront/product/form.hbs b/addon/components/storefront/product/form.hbs index 2bc38d33..1ce3f389 100644 --- a/addon/components/storefront/product/form.hbs +++ b/addon/components/storefront/product/form.hbs @@ -172,7 +172,7 @@ @onChangeId={{fn (mut @product.category_uuid)}} as |model| > - {{model.name}} + {{this.categoryLabel model}}
diff --git a/addon/components/storefront/product/form.js b/addon/components/storefront/product/form.js index 3cf47199..8f758fd3 100644 --- a/addon/components/storefront/product/form.js +++ b/addon/components/storefront/product/form.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action, get } from '@ember/object'; import { inject as service } from '@ember/service'; +import { categoryPath } from '../../../utils/category-tree'; /** Which section of the page holds each field an issue can point at. */ const SECTION_FOR_FIELD = { name: 'product', price: 'pricing', sale_price: 'pricing', category: 'organization', media: 'media' }; @@ -33,6 +34,15 @@ function number(value) { */ export default class StorefrontProductFormComponent extends Component { @service intl; + @service store; + + /** A category option's place in the store's tree: "Drinks › Coffee › Cold brew". */ + categoryLabel = (category) => { + const ownerId = get(category, 'owner_uuid'); + const categories = this.store.peekAll('category').filter((record) => get(record, 'for') === 'storefront_product' && get(record, 'owner_uuid') === ownerId); + + return categoryPath(categories, category); + }; @tracked moreOpen = false; /** The language whose fields are on screen in the translations section. */ @tracked translationLanguage = null; diff --git a/addon/controllers/catalogs/index/edit.js b/addon/controllers/catalogs/index/edit.js index 09ce53dd..936755ac 100644 --- a/addon/controllers/catalogs/index/edit.js +++ b/addon/controllers/catalogs/index/edit.js @@ -2,9 +2,19 @@ import Controller from '@ember/controller'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { dasherize } from '@ember/string'; import { task } from 'ember-concurrency'; +import { flattenCategoryTree, MAX_CATEGORY_DEPTH } from '../../../utils/category-tree'; + +/** A catalog category's key: its id once saved, a client key until then. */ +const keyOf = (category) => category.id ?? category.key ?? null; +/** The key of the category it sits under: a saved parent's id, or a new parent's key. */ +const parentKeyOf = (category) => category.parent_uuid ?? category.parent_key ?? null; +const newKey = () => `new-${Math.random().toString(36).slice(2, 10)}`; const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; +/** What the category modal edits; a clone or cancel carries or restores exactly these. */ +const CATEGORY_DETAILS = ['description', 'icon', 'icon_color', 'icon_file_uuid', 'icon_url']; export default class CatalogsIndexEditController extends Controller { @service store; @@ -13,6 +23,8 @@ export default class CatalogsIndexEditController extends Controller { @service modalsManager; @service hostRouter; @service crud; + @service fetch; + @service currentUser; @tracked catalog; @tracked products = []; @tracked stores = []; @@ -22,8 +34,6 @@ export default class CatalogsIndexEditController extends Controller { @tracked pickerQuery = ''; @tracked pickerSelection = []; @tracked newCategoryName = ''; - @tracked renamingCategory = null; - @tracked renameValue = ''; @tracked revision = 0; /** The open editor section, kept in the URL so links can land on Served by or Hours. */ queryParams = [{ activeSection: 'section' }]; @@ -36,17 +46,69 @@ export default class CatalogsIndexEditController extends Controller { return this.catalog?.categories?.toArray?.() ?? Array.from(this.catalog?.categories ?? []); } - get categoryRows() { + /** The menu in tree order: Lunch, Lunch › Tacos, Lunch › Tacos › Vegetarian, Drinks. */ + get categoryTree() { this.revision; - return this.categories.map((category, index) => ({ - category, - index, - count: category.products?.length ?? 0, - isSelected: category === this.selectedCategory, - isFirst: index === 0, - isLast: index === this.categories.length - 1, - })); + return flattenCategoryTree(this.categories, { idOf: keyOf, parentOf: parentKeyOf }); + } + + get categoryRows() { + const rows = this.categoryTree; + + return rows.map((row, index) => { + const siblings = rows.filter((candidate) => candidate.parentId === row.parentId); + const position = siblings.indexOf(row); + const previous = siblings[position - 1]; + const height = this.levelsUnder(rows, index); + + return { + category: row.category, + index, + depth: row.depth, + path: row.path, + count: row.category.products?.length ?? 0, + isSelected: row.category === this.selectedCategory, + isFirst: position === 0, + isLast: position === siblings.length - 1, + // Under the category above it, if that keeps the branch within three levels. + canIndent: Boolean(previous) && previous.depth + 1 + height < MAX_CATEGORY_DEPTH, + canOutdent: row.depth > 0, + canAddSubcategory: row.depth < MAX_CATEGORY_DEPTH - 1, + }; + }); + } + + /** How many levels sit under the row at `index` in tree order. */ + levelsUnder(rows, index) { + let deepest = rows[index].depth; + for (let next = index + 1; next < rows.length && rows[next].depth > rows[index].depth; next++) { + deepest = Math.max(deepest, rows[next].depth); + } + + return deepest - rows[index].depth; + } + + categoryByKey(key) { + return key ? (this.categories.find((category) => keyOf(category) === key) ?? null) : null; + } + + /** Place a category under another (or at the top): a saved parent by id, a new one by its key. */ + setParent(category, parent) { + if (parent && !keyOf(parent)) { + parent.set('key', newKey()); + } + + category.setProperties({ + parent_uuid: parent?.id ?? null, + parent_key: parent && !parent.id ? parent.key : null, + }); + } + + /** Number the categories in tree order, which keeps each one's place among its siblings. */ + renumber() { + flattenCategoryTree(this.categories, { idOf: keyOf, parentOf: parentKeyOf }).forEach((row, position) => row.category.set('order', position)); + this.touch(); } get selectedProducts() { @@ -223,6 +285,12 @@ export default class CatalogsIndexEditController extends Controller { categories: this.categories.map((category) => ({ id: category.id, name: category.name, + parent: parentKeyOf(category), + order: Number(category.order ?? 0), + description: category.description, + icon: category.icon, + icon_color: category.icon_color, + icon_file_uuid: category.icon_file_uuid, products: (category.products?.toArray?.() ?? Array.from(category.products ?? [])).map((product) => product.id), overrides: this.overridesFor(category), })), @@ -292,50 +360,127 @@ export default class CatalogsIndexEditController extends Controller { return; } - const category = this.store.createRecord('catalog-category', { name, for: 'storefront_catalog', order: this.categories.length }); + const category = this.store.createRecord('catalog-category', { name, for: 'storefront_catalog', order: this.categories.length, key: newKey() }); this.catalog.categories.pushObject(category); this.newCategoryName = ''; this.selectedCategory = category; this.touch(); } - @action startRename(category) { - this.renamingCategory = category; - this.renameValue = category.name ?? ''; + /** + * Edit a category's name, description and icon in the category modal. Like every other + * edit here the changes stay on the page until the catalog is saved; cancel puts them back. + */ + @action editCategory(category) { + const before = category.getProperties('name', ...CATEGORY_DETAILS); + + return this.modalsManager.show('modals/create-product-category', { + title: this.intl.t('storefront.catalogs.editor.edit-category-title', { name: category.name }), + acceptButtonText: this.intl.t('storefront.catalogs.editor.apply-category'), + acceptButtonIcon: 'check', + declineButtonIcon: 'times', + declineButtonIconPrefix: 'fas', + category, + uploadNewPhoto: (file) => this.uploadCategoryIcon(category, file), + confirm: (modal) => { + const name = String(category.name ?? '').trim(); + + if (!name) { + return this.notifications.warning(this.intl.t('storefront.catalogs.editor.category-name-required')); + } + + category.set('name', name); + this.touch(); + modal.done(); + }, + decline: (modal) => { + category.setProperties(before); + modal.done(); + }, + }); } - @action setRenameValue(event) { - this.renameValue = event.target.value; + uploadCategoryIcon(category, file) { + const companyId = category.company_uuid ?? this.currentUser.companyId; + const params = { + path: `uploads/${companyId}/catalog-category-icon/${dasherize(category.name || 'category')}`, + subject_type: 'category', + type: 'category_icon', + }; + + // A category added on this page has no uuid until the catalog is saved. + if (!category.isNew && category.id) { + params.subject_uuid = category.id; + } + + return this.fetch.uploadFile.perform(file, params, (uploadedFile) => { + category.setProperties({ icon_file_uuid: uploadedFile.id, icon_url: uploadedFile.url }); + }); } - @action commitRename(event) { - event?.preventDefault?.(); + /** Up or down among the categories it shares a parent with. */ + @action moveCategory(category, direction) { + const tree = this.categoryTree; + const parentId = tree.find((row) => row.category === category)?.parentId ?? null; + const siblings = tree.filter((row) => row.parentId === parentId).map((row) => row.category); + const index = siblings.indexOf(category); + const target = index + direction; - if (this.renamingCategory && this.renameValue.trim()) { - this.renamingCategory.set('name', this.renameValue.trim()); - this.touch(); + if (index < 0 || target < 0 || target >= siblings.length) { + return; } - this.renamingCategory = null; + const orders = siblings.map((sibling) => Number(sibling.order ?? 0)); + siblings.splice(index, 1); + siblings.splice(target, 0, category); + siblings.forEach((sibling, position) => sibling.set('order', orders[position])); + this.renumber(); } - @action cancelRename() { - this.renamingCategory = null; + /** Under the category above it, as its last subcategory. */ + @action indentCategory(category) { + const row = this.categoryRows.find((candidate) => candidate.category === category); + if (!row?.canIndent) { + return; + } + + const tree = this.categoryTree; + const siblings = tree.filter((candidate) => candidate.parentId === tree[row.index].parentId); + const previous = siblings[siblings.findIndex((candidate) => candidate.category === category) - 1].category; + this.setParent(category, previous); + category.set('order', Number.MAX_SAFE_INTEGER); + this.renumber(); } - @action moveCategory(category, direction) { - const list = this.catalog.categories; - const index = list.indexOf(category); - const target = index + direction; + /** Up a level, right after the category it was under. */ + @action outdentCategory(category) { + const parent = this.categoryByKey(parentKeyOf(category)); + if (!parent) { + return; + } + + this.setParent(category, this.categoryByKey(parentKeyOf(parent))); + category.set('order', Number(parent.order ?? 0) + 0.5); + this.renumber(); + } - if (index < 0 || target < 0 || target >= list.length) { + @action addSubcategory(parent) { + if (!this.categoryRows.find((row) => row.category === parent)?.canAddSubcategory) { return; } - list.removeObject(category); - list.insertAt(target, category); - list.forEach((item, position) => item.set('order', position)); - this.touch(); + const category = this.store.createRecord('catalog-category', { + name: this.intl.t('storefront.catalogs.editor.new-subcategory-name'), + for: 'storefront_catalog', + key: newKey(), + order: Number.MAX_SAFE_INTEGER, + }); + this.setParent(category, parent); + this.catalog.categories.pushObject(category); + this.renumber(); + this.selectedCategory = category; + // Named, described and given an icon in the category modal, like any category. + this.editCategory(category); } @action removeCategory(category) { @@ -345,7 +490,16 @@ export default class CatalogsIndexEditController extends Controller { acceptButtonText: this.intl.t('common.delete'), acceptButtonScheme: 'danger', confirm: (modal) => { + // Its subcategories move up to where it was. + const parent = this.categoryByKey(parentKeyOf(category)); + this.categories + .filter((child) => parentKeyOf(child) === keyOf(category)) + .forEach((child) => { + this.setParent(child, parent); + child.set('order', Number(category.order ?? 0) + Number(child.order ?? 0) / 1000); + }); this.catalog.categories.removeObject(category); + this.renumber(); if (this.selectedCategory === category) { this.selectedCategory = this.categories[0] ?? null; @@ -494,8 +648,22 @@ export default class CatalogsIndexEditController extends Controller { status: 'draft', }); + // Clones keep the tree: each finds its parent's clone by key. + const clones = new Map( + this.categories.map((category) => [ + keyOf(category), + this.store.createRecord('catalog-category', { + ...category.getProperties(CATEGORY_DETAILS), + name: category.name, + for: 'storefront_catalog', + order: category.order, + key: newKey(), + }), + ]) + ); this.categories.forEach((category) => { - const clone = this.store.createRecord('catalog-category', { name: category.name, for: 'storefront_catalog', order: category.order }); + const clone = clones.get(keyOf(category)); + clone.parent_key = clones.get(parentKeyOf(category))?.key ?? null; (category.products?.toArray?.() ?? []).forEach((product) => clone.products.pushObject(product)); copy.categories.pushObject(clone); }); diff --git a/addon/controllers/products/index.js b/addon/controllers/products/index.js index 1e5de133..e5ad7822 100644 --- a/addon/controllers/products/index.js +++ b/addon/controllers/products/index.js @@ -7,6 +7,7 @@ import { alias } from '@ember/object/computed'; import { dasherize } from '@ember/string'; import { isBlank } from '@ember/utils'; import { timeout, task } from 'ember-concurrency'; +import { categoryAndDescendantIds, categoryPath } from '../../utils/category-tree'; export default class ProductsIndexController extends BaseController { @controller('products.index.index') productsIndexIndexController; @@ -55,8 +56,15 @@ export default class ProductsIndexController extends BaseController { return this.summary?.by_category ?? {}; } - /** The header's meta line: products and categories in this store. */ + /** The header's meta line: products and categories in this store, or the open category's place and products. */ get headerMeta() { + if (this.category) { + const ids = categoryAndDescendantIds(this.productCategories, this.category); + const products = [...ids].reduce((sum, id) => sum + Number(this.categoryCounts[id] ?? 0), 0); + + return this.intl.t('storefront.products.index.category-meta', { path: categoryPath(this.productCategories, this.category), products }); + } + const categories = this.model?.length ?? Object.keys(this.categoryCounts).length; // Before the first list response there is no product count to claim, only the categories. @@ -396,21 +404,33 @@ export default class ProductsIndexController extends BaseController { this.transitionToRoute('products.index.category', category.slug); } - @action createNewProductCategory() { + /** A subcategory: the new category starts under the given one. */ + @action createProductSubcategory(parent) { + return this.createNewProductCategory(parent); + } + + @action createNewProductCategory(parent = null) { + const parentCategory = parent && typeof parent === 'object' && parent.id && !parent.target ? parent : null; const category = this.store.createRecord('category', { company_uuid: this.currentUser.companyId, owner_uuid: this.currentUser.getOption('activeStorefront'), owner_type: 'storefront:store', for: 'storefront_product', + parent_uuid: parentCategory?.id ?? null, + parent: parentCategory, }); this.modalsManager.show('modals/create-product-category', { - title: this.intl.t('storefront.products.index.create-new-product-category'), + title: parentCategory + ? this.intl.t('storefront.products.index.create-new-product-subcategory', { parent: parentCategory.name }) + : this.intl.t('storefront.products.index.create-new-product-category'), acceptButtonIcon: 'check', acceptButtonIconPrefix: 'fas', declineButtonIcon: 'times', declineButtonIconPrefix: 'fas', category, + // The store's categories, for the parent picker. + categories: this.productCategories, uploadNewPhoto: (file) => { this.fetch.uploadFile.perform( file, @@ -432,14 +452,26 @@ export default class ProductsIndexController extends BaseController { confirm: (modal) => { modal.startLoading(); - return category.save().then(() => { - this.notifications.success(this.intl.t('storefront.products.index.product-category-created-success')); - return this.hostRouter.refresh(); - }); + return category + .save() + .then(() => { + this.notifications.success(this.intl.t('storefront.products.index.product-category-created-success')); + return this.hostRouter.refresh(); + }) + .catch((error) => { + // A parent the server refuses (too deep, another store's) is said, and the form stays open. + modal.stopLoading(); + this.notifications.serverError(error); + }); }, }); } + /** The store's product categories, saved ones only. */ + get productCategories() { + return (this.model?.toArray?.() ?? Array.from(this.model ?? [])).filter((category) => category && !category.isNew && !category.isDeleted); + } + @action importProducts() { const checkQueue = () => { const uploadQueue = this.modalsManager.getOption('uploadQueue'); diff --git a/addon/controllers/products/index/category.js b/addon/controllers/products/index/category.js index a7ea68e4..15b9a5a7 100644 --- a/addon/controllers/products/index/category.js +++ b/addon/controllers/products/index/category.js @@ -10,6 +10,7 @@ import { timeout, task } from 'ember-concurrency'; export default class ProductsIndexCategoryController extends BaseController { @controller('products.index') productsIndexController; @service intl; + @service store; @service modalsManager; @service fetch; @service hostRouter; @@ -130,6 +131,10 @@ export default class ProductsIndexCategoryController extends BaseController { declineButtonIcon: 'times', declineButtonIconPrefix: 'fas', category, + // The store's other categories, for the parent picker. + categories: this.store + .peekAll('category') + .filter((record) => record.for === 'storefront_product' && record.owner_uuid === category.owner_uuid && !record.isNew && !record.isDeleted), uploadNewPhoto: (file) => { this.fetch.uploadFile.perform( file, @@ -151,9 +156,15 @@ export default class ProductsIndexCategoryController extends BaseController { confirm: (modal) => { modal.startLoading(); - return category.save().then(() => { - this.notifications.success(this.intl.t('storefront.products.index.category.category-changes-saved-success')); - }); + return category + .save() + .then(() => { + this.notifications.success(this.intl.t('storefront.products.index.category.category-changes-saved-success')); + }) + .catch((error) => { + modal.stopLoading(); + this.notifications.serverError(error); + }); }, }); } diff --git a/addon/models/catalog-category.js b/addon/models/catalog-category.js index b6e19968..e7b7fda8 100644 --- a/addon/models/catalog-category.js +++ b/addon/models/catalog-category.js @@ -5,4 +5,7 @@ export default class CatalogCategoryModel extends CategoryModel { @hasMany('products', { async: false, inverse: 'catalogCategories' }) products; /** Per-catalog overrides keyed by product uuid: `{ price, is_available }`, each null to inherit from the store. */ @attr('raw') product_overrides; + /** A new category's key until it is saved, and the key of a new parent it sits under: the catalog save places it by these. */ + @attr('string') key; + @attr('string') parent_key; } diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index d6f0da31..5b6d3673 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -4800,6 +4800,14 @@ body[data-theme='dark'] .storefront-catalog-category { border-color: #374151; } +.storefront-catalog-category--depth-1 { + padding-left: 1.5rem; +} + +.storefront-catalog-category--depth-2 { + padding-left: 2.5rem; +} + .storefront-catalog-category.is-selected { background-color: #dbeafe; } @@ -4842,7 +4850,15 @@ body[data-theme='dark'] .storefront-catalog-category__select { display: flex; } -.storefront-catalog-category__rename { +.storefront-catalog-category__icon { + flex: 0 0 auto; + width: 1.25rem; + height: 1.25rem; + border-radius: 0.25rem; + object-fit: cover; +} + +.storefront-catalog-category__icon + .storefront-catalog-category__name { flex: 1 1 auto; } @@ -5614,6 +5630,38 @@ body[data-theme='dark'] .storefront-product-category__caret:hover { color: #e5e7eb; } +.storefront-product-category__add { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + margin-left: auto; + border-radius: 0.25rem; + color: #6b7280; + visibility: hidden; +} + +.storefront-product-category:hover .storefront-product-category__add, +.storefront-product-category:focus-within .storefront-product-category__add { + visibility: visible; +} + +.storefront-product-category__add:hover { + background-color: #e5e7eb; + color: #374151; +} + +body[data-theme='dark'] .storefront-product-category__add:hover { + background-color: #374151; + color: #e5e7eb; +} + +.storefront-product-category__add + .storefront-product-category__count { + margin-left: 0.25rem; +} + .storefront-product-category__count { flex: 0 0 auto; margin-left: auto; diff --git a/addon/templates/catalogs/index/edit.hbs b/addon/templates/catalogs/index/edit.hbs index 4455b542..c55aed67 100644 --- a/addon/templates/catalogs/index/edit.hbs +++ b/addon/templates/catalogs/index/edit.hbs @@ -28,23 +28,30 @@