diff --git a/app/assets/javascripts/discourse/app/components/sidebar/edit-navigation-menu/categories-modal.gjs b/app/assets/javascripts/discourse/app/components/sidebar/edit-navigation-menu/categories-modal.gjs index d512dfddda6..b924e889a05 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/edit-navigation-menu/categories-modal.gjs +++ b/app/assets/javascripts/discourse/app/components/sidebar/edit-navigation-menu/categories-modal.gjs @@ -6,7 +6,8 @@ import { action } from "@ember/object"; import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import { service } from "@ember/service"; import { TrackedSet } from "@ember-compat/tracked-built-ins"; -import { gt, has, includes, not } from "truth-helpers"; +import { eq, gt, has } from "truth-helpers"; +import DButton from "discourse/components/d-button"; import EditNavigationMenuModal from "discourse/components/sidebar/edit-navigation-menu/modal"; import borderColor from "discourse/helpers/border-color"; import categoryBadge from "discourse/helpers/category-badge"; @@ -18,6 +19,45 @@ import { INPUT_DELAY } from "discourse-common/config/environment"; import i18n from "discourse-common/helpers/i18n"; import discourseDebounce from "discourse-common/lib/debounce"; +class ActionSerializer { + constructor(perform) { + this.perform = perform; + this.processing = false; + this.queued = false; + } + + async trigger() { + this.queued = true; + + if (!this.processing) { + this.processing = true; + + while (this.queued) { + this.queued = false; + await this.perform(); + } + + this.processing = false; + } + } +} + +// Given an async method that takes no parameters, produce a method that +// triggers the original method only if it is not currently executing it, +// otherwise it will queue up to one execution of the method +function serialized(target, key, descriptor) { + const originalMethod = descriptor.value; + + descriptor.value = function () { + this[`_${key}_serializer`] ||= new ActionSerializer(() => + originalMethod.apply(this) + ); + this[`_${key}_serializer`].trigger(); + }; + + return descriptor; +} + // Given a list, break into chunks starting a new chunk whenever the predicate // is true for an element. function splitWhere(elements, f) { @@ -30,20 +70,39 @@ function splitWhere(elements, f) { }, []); } -function findAncestors(categories) { - let categoriesToCheck = categories; - const ancestors = []; +// categories must be topologically sorted so that the parents appear before +// the children +function findPartialCategories(categories) { + const categoriesById = new Map( + categories.map((category) => [category.id, category]) + ); + const subcategoryCounts = new Map(); + const subcategoryCountsRecursive = new Map(); + const partialCategoryInfos = new Map(); - for (let i = 0; i < 3; i++) { - categoriesToCheck = categoriesToCheck - .map((c) => Category.findById(c.parent_category_id)) - .filter(Boolean) - .uniqBy((c) => c.id); + for (const category of categories.slice().reverse()) { + const count = subcategoryCounts.get(category.parent_category_id) || 0; + subcategoryCounts.set(category.parent_category_id, count + 1); - ancestors.push(...categoriesToCheck); + const recursiveCount = + subcategoryCountsRecursive.get(category.parent_category_id) || 0; + + subcategoryCountsRecursive.set( + category.parent_category_id, + recursiveCount + (subcategoryCountsRecursive.get(category.id) || 0) + 1 + ); } - return ancestors; + for (const [id, count] of subcategoryCounts) { + if (count === 5 && categoriesById.has(id)) { + partialCategoryInfos.set(id, { + level: categoriesById.get(id).level + 1, + offset: subcategoryCountsRecursive.get(id), + }); + } + } + + return partialCategoryInfos; } export default class SidebarEditNavigationMenuCategoriesModal extends Component { @@ -52,20 +111,19 @@ export default class SidebarEditNavigationMenuCategoriesModal extends Component @service siteSettings; @tracked initialLoad = true; - @tracked filteredCategoriesGroupings = []; - @tracked filteredCategoryIds = []; + @tracked fetchedCategoriesGroupings = []; @tracked - selectedSidebarCategoryIds = new TrackedSet([ + selectedCategoryIds = new TrackedSet([ ...this.currentUser.sidebar_category_ids, ]); - hasMorePages; + selectedFilter = ""; + selectedMode = "everything"; loadedFilter; loadedMode; loadedPage; - processing = false; - requestedFilter; - requestedMode; saving = false; + loadAnotherPage = false; + unseenCategoryIdsChanged = false; observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { @@ -80,45 +138,102 @@ export default class SidebarEditNavigationMenuCategoriesModal extends Component constructor() { super(...arguments); - this.setFilterAndMode("", "everything"); + this.subcategoryLoadList = []; + this.performSearch(); } - setFilteredCategories(categories) { - this.filteredCategories = categories; - const ancestors = findAncestors(categories); - const allCategories = categories.concat(ancestors).uniqBy((c) => c.id); + recomputeGroupings() { + const categoriesWithShowMores = this.fetchedCategories.flatMap((el, i) => { + const result = [{ type: "category", category: el }]; - this.filteredCategoriesGroupings = splitWhere( - Category.sortCategories(allCategories), - (category) => category.parent_category_id === undefined - ); + const elID = el.id; + const elParentID = el.parent_category_id; + const nextParentID = this.fetchedCategories[i + 1]?.parent_category_id; - this.filteredCategoryIds = categories.map((c) => c.id); - } + const nextIsSibling = nextParentID === elParentID; + const nextIsChild = nextParentID === elID; - concatFilteredCategories(categories) { - this.setFilteredCategories(this.filteredCategories.concat(categories)); - } + if ( + !nextIsSibling && + !nextIsChild && + this.partialCategoryInfos.has(elParentID) + ) { + const { level, offset } = this.partialCategoryInfos.get(elParentID); - setFetchedCategories(mode, categories) { - this.setFilteredCategories(this.applyMode(mode, categories)); - } - - concatFetchedCategories(mode, categories) { - this.concatFilteredCategories(this.applyMode(mode, categories)); - } - - applyMode(mode, categories) { - return categories.filter((c) => { - switch (mode) { - case "everything": - return true; - case "only-selected": - return this.selectedSidebarCategoryIds.has(c.id); - case "only-unselected": - return !this.selectedSidebarCategoryIds.has(c.id); + result.push({ + type: "show-more", + id: elParentID, + level, + offset, + }); } - }); + + return result; + }, []); + + this.fetchedCategoriesGroupings = splitWhere( + categoriesWithShowMores, + (c) => + c.type === "category" && c.category.parent_category_id === undefined + ); + } + + setFetchedCategories(categories) { + this.fetchedCategories = categories; + this.partialCategoryInfos = findPartialCategories(categories); + this.recomputeGroupings(); + } + + concatFetchedCategories(categories) { + this.fetchedCategories = this.fetchedCategories.concat(categories); + + // In order to find partially loaded categories correctly, we need to + // ensure that we account for categories that may have been partially + // loaded, because the total number of categories in the response clipped + // them off. + if (categories[0].parent_category_id !== undefined) { + const index = this.fetchedCategories.findLastIndex( + (element) => element.parent_category_id === undefined + ); + + categories = [...this.fetchedCategories.slice(index), ...categories]; + } + + this.partialCategoryInfos = new Map([ + ...this.partialCategoryInfos, + ...findPartialCategories(categories), + ]); + + this.recomputeGroupings(); + } + + substituteInFetchedCategories(id, subcategories, offset) { + this.partialCategoryInfos.delete(id); + this.recomputeGroupings(); + + if (subcategories.length !== 0) { + const index = + this.fetchedCategories.findLastIndex( + (c) => c.parent_category_id === id + ) + 1; + + this.fetchedCategories = [ + ...this.fetchedCategories.slice(0, index), + ...subcategories, + ...this.fetchedCategories.slice(index), + ]; + + this.partialCategoryInfos = new Map([ + ...this.partialCategoryInfos, + ...findPartialCategories(subcategories), + ]); + + this.partialCategoryInfos.set(id, { + offset: offset + subcategories.length, + }); + + this.recomputeGroupings(); + } } @action @@ -127,136 +242,149 @@ export default class SidebarEditNavigationMenuCategoriesModal extends Component this.observer.observe(element); } - async searchCategories(filter, mode) { - if (filter === "" && mode === "only-selected") { - this.setFilteredCategories( - await Category.asyncFindByIds([...this.selectedSidebarCategoryIds]) - ); + searchOpts() { + const requestedMode = this.selectedMode; + const requestedCategoryIds = [...this.selectedCategoryIds]; + const opts = { includeUncategorized: false }; - this.loadedPage = null; - this.hasMorePages = false; - } else { - const { categories } = await Category.asyncSearch(filter, { - includeAncestors: true, - includeUncategorized: false, - }); - - this.setFetchedCategories(mode, categories); - - this.loadedPage = 1; - this.hasMorePages = true; + if (requestedMode === "only-selected") { + opts.only = requestedCategoryIds; + } else if (requestedMode === "only-unselected") { + opts.except = requestedCategoryIds; } + + return opts; } - async setFilterAndMode(newFilter, newMode) { - this.requestedFilter = newFilter; - this.requestedMode = newMode; + @serialized + async performSearch() { + const requestedFilter = this.selectedFilter; + const requestedMode = this.selectedMode; + const selectedCategoriesNeedsUpdate = + this.unseenCategoryIdsChanged && requestedMode !== "everything"; - if (!this.processing) { - this.processing = true; + // Is the current set of displayed categories up-to-date? + if ( + requestedFilter === this.loadedFilter && + requestedMode === this.loadedMode && + !selectedCategoriesNeedsUpdate + ) { + // The shown categories are up-to-date, so we can do elaboration + if (this.loadAnotherPage && !this.lastPage) { + const requestedPage = this.loadedPage + 1; + const opts = { page: requestedPage, ...this.searchOpts() }; - try { - while ( - this.loadedFilter !== this.requestedFilter || - this.loadedMode !== this.requestedMode - ) { - const filter = this.requestedFilter; - const mode = this.requestedMode; + const categories = await Category.asyncHierarchicalSearch( + requestedFilter, + opts + ); - await this.searchCategories(filter, mode); - - this.loadedFilter = filter; - this.loadedMode = mode; - this.initialLoad = false; + if (categories.length === 0) { + this.lastPage = true; + } else { + this.concatFetchedCategories(categories); } - } finally { - this.processing = false; + + this.loadAnotherPage = false; + this.loadedPage = requestedPage; + } else if (this.subcategoryLoadList.length !== 0) { + const { id, offset } = this.subcategoryLoadList.shift(); + const opts = { parentCategoryId: id, offset, ...this.searchOpts() }; + + let subcategories = await Category.asyncHierarchicalSearch( + requestedFilter, + opts + ); + + this.substituteInFetchedCategories(id, subcategories, offset); } + } else { + // The shown categories are stale, refresh everything + const requestedCategoryIds = [...this.selectedCategoryIds]; + this.unseenCategoryIdsChanged = false; + + this.setFetchedCategories( + await Category.asyncHierarchicalSearch( + requestedFilter, + this.searchOpts() + ) + ); + + this.loadedFilter = requestedFilter; + this.loadedMode = requestedMode; + this.loadedCategoryIds = requestedCategoryIds; + this.loadedPage = 1; + this.lastPage = false; + this.initialLoad = false; + this.loadAnotherPage = false; } } async loadMore() { - if (!this.processing && this.hasMorePages) { - this.processing = true; - - try { - const page = this.loadedPage + 1; - const { categories } = await Category.asyncSearch( - this.requestedFilter, - { - includeAncestors: true, - includeUncategorized: false, - page, - } - ); - this.loadedPage = page; - - if (categories.length === 0) { - this.hasMorePages = false; - } else { - this.concatFetchedCategories(this.requestedMode, categories); - } - } finally { - this.processing = false; - } - - if ( - this.loadedFilter !== this.requestedFilter || - this.loadedMode !== this.requestedMode - ) { - await this.setFilterAndMode(this.requestedFilter, this.requestedMode); - } - } + this.loadAnotherPage = true; + this.debouncedSendRequest(); } - debouncedSetFilterAndMode(filter, mode) { - discourseDebounce(this, this.setFilterAndMode, filter, mode, INPUT_DELAY); + @action + async loadSubcategories(id, offset) { + this.subcategoryLoadList.push({ id, offset }); + this.debouncedSendRequest(); + } + + debouncedSendRequest() { + discourseDebounce(this, this.performSearch, INPUT_DELAY); } @action resetFilter() { - this.debouncedSetFilterAndMode(this.requestedFilter, "everything"); + this.selectedMode = "everything"; + this.debouncedSendRequest(); } @action filterSelected() { - this.debouncedSetFilterAndMode(this.requestedFilter, "only-selected"); + this.selectedMode = "only-selected"; + this.debouncedSendRequest(); } @action filterUnselected() { - this.debouncedSetFilterAndMode(this.requestedFilter, "only-unselected"); + this.selectedMode = "only-unselected"; + this.debouncedSendRequest(); } @action onFilterInput(filter) { - this.debouncedSetFilterAndMode( - filter.toLowerCase().trim(), - this.requestedMode - ); + this.selectedFilter = filter.toLowerCase().trim(); + this.debouncedSendRequest(); } @action deselectAll() { - this.selectedSidebarCategoryIds.clear(); + this.selectedCategoryIds.clear(); + this.unseenCategoryIdsChanged = true; + this.debouncedSendRequest(); } @action toggleCategory(categoryId) { - if (this.selectedSidebarCategoryIds.has(categoryId)) { - this.selectedSidebarCategoryIds.delete(categoryId); + if (this.selectedCategoryIds.has(categoryId)) { + this.selectedCategoryIds.delete(categoryId); } else { - this.selectedSidebarCategoryIds.add(categoryId); + this.selectedCategoryIds.add(categoryId); } } @action resetToDefaults() { - this.selectedSidebarCategoryIds = new TrackedSet( + this.selectedCategoryIds = new TrackedSet( this.siteSettings.default_navigation_menu_categories .split("|") .map((id) => parseInt(id, 10)) ); + + this.unseenCategoryIdsChanged = true; + this.debouncedSendRequest(); } @action @@ -264,9 +392,7 @@ export default class SidebarEditNavigationMenuCategoriesModal extends Component this.saving = true; const initialSidebarCategoryIds = this.currentUser.sidebar_category_ids; - this.currentUser.set("sidebar_category_ids", [ - ...this.selectedSidebarCategoryIds, - ]); + this.currentUser.set("sidebar_category_ids", [...this.selectedCategoryIds]); try { await this.currentUser.save(["sidebar_category_ids"]); @@ -307,60 +433,74 @@ export default class SidebarEditNavigationMenuCategoriesModal extends Component {{loadingSpinner size="small"}} {{else}} - {{#each this.filteredCategoriesGroupings as |categories|}} + {{#each this.fetchedCategoriesGroupings as |categories|}}