From 4ec3d38836296cc76570b0adfc1c2d7609747e42 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sun, 18 Oct 2026 04:45:05 +0000 Subject: [PATCH] feat(tracker): group issue status selector by category with collapsible groups The issue status selection popup listed all statuses of a project type as one flat list, which gets hard to scan once a workflow has many statuses. SelectPopup gets an opt-in collapsibleCategories mode: category headers become rows that can be toggled by click or by Enter during keyboard navigation, show the number of statuses in the group, and the collapsed state is remembered in local storage under a caller supplied key. While searching all groups are shown expanded. The tracker status editor and status selector order statuses by status category and enable this mode when a project type has 10 or more statuses; with fewer statuses the popup stays unchanged. Fixes #8857 Signed-off-by: Michael Uray --- .../__test__/selectPopupCategories.test.ts | 90 +++++++++ packages/ui/src/components/SelectPopup.svelte | 181 +++++++++++++----- packages/ui/src/selectPopupCategories.ts | 95 +++++++++ .../src/__tests__/statusCategories.test.ts | 73 +++++++ .../src/components/issues/StatusEditor.svelte | 28 ++- .../components/issues/StatusSelector.svelte | 20 +- .../tracker-resources/src/statusCategories.ts | 47 +++++ 7 files changed, 470 insertions(+), 64 deletions(-) create mode 100644 packages/ui/src/__test__/selectPopupCategories.test.ts create mode 100644 packages/ui/src/selectPopupCategories.ts create mode 100644 plugins/tracker-resources/src/__tests__/statusCategories.test.ts create mode 100644 plugins/tracker-resources/src/statusCategories.ts diff --git a/packages/ui/src/__test__/selectPopupCategories.test.ts b/packages/ui/src/__test__/selectPopupCategories.test.ts new file mode 100644 index 00000000000..e7b0022a4d0 --- /dev/null +++ b/packages/ui/src/__test__/selectPopupCategories.test.ts @@ -0,0 +1,90 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { IntlString } from '@hcengineering/platform' +import { buildSelectPopupRows, loadCollapsedCategories, saveCollapsedCategories } from '../selectPopupCategories' +import type { SelectPopupValueType } from '../types' + +const todo = { label: 'cat:ToDo' as IntlString } +const done = { label: 'cat:Done' as IntlString } + +const items: SelectPopupValueType[] = [ + { id: 'a', text: 'A', category: todo }, + { id: 'b', text: 'B', category: todo }, + { id: 'c', text: 'C', category: done }, + { id: 'd', text: 'D' } +] + +function describeRows (rows: ReturnType): string[] { + return rows.map((row) => + row.kind === 'category' + ? `[${row.category.label} ${row.count}${row.collapsed ? ' collapsed' : ''}]` + : String(row.item.id) + ) +} + +describe('buildSelectPopupRows', () => { + it('adds a header before each category group', () => { + expect(describeRows(buildSelectPopupRows(items, new Set()))).toEqual([ + '[cat:ToDo 2]', + 'a', + 'b', + '[cat:Done 1]', + 'c', + 'd' + ]) + }) + + it('hides items of collapsed categories but keeps the header with the item count', () => { + expect(describeRows(buildSelectPopupRows(items, new Set([todo.label])))).toEqual([ + '[cat:ToDo 2 collapsed]', + '[cat:Done 1]', + 'c', + 'd' + ]) + }) + + it('expands all groups when requested, e.g. while searching', () => { + expect(describeRows(buildSelectPopupRows(items, new Set([todo.label, done.label]), true))).toEqual([ + '[cat:ToDo 2]', + 'a', + 'b', + '[cat:Done 1]', + 'c', + 'd' + ]) + }) +}) + +describe('collapsed categories storage', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('remembers collapsed categories per key', () => { + saveCollapsedCategories('status', new Set([todo.label])) + expect(Array.from(loadCollapsedCategories('status'))).toEqual([todo.label]) + expect(loadCollapsedCategories('other').size).toBe(0) + expect(loadCollapsedCategories(undefined).size).toBe(0) + + saveCollapsedCategories('status', new Set()) + expect(loadCollapsedCategories('status').size).toBe(0) + }) + + it('ignores malformed stored values', () => { + localStorage.setItem('selectPopup.collapsedCategories.status', '{broken') + expect(loadCollapsedCategories('status').size).toBe(0) + }) +}) diff --git a/packages/ui/src/components/SelectPopup.svelte b/packages/ui/src/components/SelectPopup.svelte index 967babacaad..0f5f2088a8d 100644 --- a/packages/ui/src/components/SelectPopup.svelte +++ b/packages/ui/src/components/SelectPopup.svelte @@ -26,6 +26,13 @@ import Spinner from './Spinner.svelte' import IconCheck from './icons/Check.svelte' import IconSearch from './icons/Search.svelte' + import IconCollapseArrow from './icons/CollapseArrow.svelte' + import { + buildSelectPopupRows, + loadCollapsedCategories, + saveCollapsedCategories, + type SelectPopupRow + } from '../selectPopupCategories' import { translate } from '@hcengineering/platform' import { themeStore } from '@hcengineering/theme' @@ -40,6 +47,9 @@ export let embedded: boolean = false export let componentLink: boolean = false export let loading: boolean = false + // Show category headers as collapsible groups; collapse state is remembered under collapsedCategoriesKey + export let collapsibleCategories: boolean = false + export let collapsedCategoriesKey: string | undefined = undefined let popupElement: HTMLDivElement | undefined = undefined export let search: string = '' @@ -53,6 +63,27 @@ let selected: any + let collapsedCategories = loadCollapsedCategories(collapsedCategoriesKey) + + function toggleCategory (label: string): void { + if (collapsedCategories.has(label)) { + collapsedCategories.delete(label) + } else { + collapsedCategories.add(label) + } + collapsedCategories = collapsedCategories + saveCollapsedCategories(collapsedCategoriesKey, collapsedCategories) + } + + function selectRow (row: SelectPopupRow | undefined): void { + if (row === undefined) return + if (row.kind === 'category') { + toggleCategory(row.category.label) + } else { + sendSelect(row.item.id) + } + } + function sendSelect (id: SelectPopupValueType['id']): void { selected = id if (onSelect) { @@ -84,7 +115,7 @@ if (key.code === 'Enter') { key.preventDefault() key.stopPropagation() - sendSelect(filteredObjects[selection].id) + selectRow(rows[selection]) return true } return false @@ -112,6 +143,20 @@ (itemLabelsTranslation.get(el.label ?? '') ?? el.text ?? '').toLowerCase().includes(search.toLowerCase()) ) + $: rows = collapsibleCategories + ? buildSelectPopupRows(filteredObjects, collapsedCategories, search !== '') + : filteredObjects.map((item): SelectPopupRow => ({ kind: 'item', item })) + + // Start keyboard navigation on the first item instead of the first category header + let initialRowSelected = false + $: if (collapsibleCategories && !initialRowSelected && rows.length > 0) { + initialRowSelected = true + selection = Math.max( + 0, + rows.findIndex((row) => row.kind === 'item') + ) + } + $: huge = size === 'medium' || size === 'large' $: if (popupElement) { @@ -154,60 +199,83 @@ {/if}
- dispatch('changeContent')} - > + dispatch('changeContent')}> - {@const item = filteredObjects[itemId]} - + {:else} + {@const item = row.item} +
- + + {#if item.label} + + {/if} + {#if hasSelected} +
+ {#if item.isSelected} + + {/if} +
+ {/if} + {#if item.id === selected && loading} + + {/if} +
+ + {/if} - {@const obj = filteredObjects[row]} - {#if obj.category && ((row === 0 && obj.category.label !== undefined) || obj.category.label !== filteredObjects[row - 1]?.category?.label)} - {#if row > 0}