Files
OmniRoute/src/shared/utils/sidebarSearch.ts
Diego Rodrigues de Sa e Souza 2c413f2b75 feat: sidebar search/filter input (#4013) (#6810)
* feat(dashboard): add search/filter input to the dashboard sidebar (#4013)

Adds a search box at the top of the expanded sidebar that filters nav
sections/groups/items client-side by label, so users don't have to
hunt through the growing nav tree. Reuses the existing common.search /
common.noResults i18n keys (no new locale edits needed) and the shared
Input icon="search" pattern. Matching sections auto-expand while
searching and the accordion/pin state is restored once the query is
cleared.

Filtering logic is extracted into a pure filterSidebarSectionsByQuery()
helper (src/shared/utils/sidebarSearch.ts) so it is trivially unit
testable independent of React/next-intl/next-navigation.

* fix(test): move Sidebar.search test to a runner-collected path (test-discovery gate)
2026-07-11 05:04:13 -03:00

69 lines
2.1 KiB
TypeScript

// Pure, framework-free filtering for the sidebar quick-search (#4013).
// Operates on the already-resolved (labeled) section/child shape produced by
// Sidebar.tsx, so it has no dependency on next-intl/React and stays trivially
// unit-testable.
export interface SearchableLabeled {
label: string;
}
export interface SearchableGroup<TItem extends SearchableLabeled> {
type: "group";
items: readonly TItem[];
}
export type SearchableChild<TItem extends SearchableLabeled> =
| TItem
| SearchableGroup<TItem>;
export interface SearchableSection<TItem extends SearchableLabeled> {
children: readonly SearchableChild<TItem>[];
}
function isGroupChild<TItem extends SearchableLabeled>(
child: SearchableChild<TItem>
): child is SearchableGroup<TItem> {
return (
typeof child === "object" &&
child !== null &&
"type" in child &&
(child as { type?: unknown }).type === "group"
);
}
/**
* Filters sidebar sections by a free-text query matched (case-insensitive,
* substring) against each item's resolved label. Groups are kept only if at
* least one of their items still matches; sections are kept only if at least
* one child (flat item or non-empty group) still matches. Passing an empty/
* whitespace-only query returns the input sections unchanged.
*/
export function filterSidebarSectionsByQuery<
TItem extends SearchableLabeled,
TSection extends SearchableSection<TItem>,
>(sections: readonly TSection[], query: string): TSection[] {
const needle = query.trim().toLowerCase();
if (!needle) return [...sections];
const matches = (item: TItem) => item.label.toLowerCase().includes(needle);
const result: TSection[] = [];
for (const section of sections) {
const children: SearchableChild<TItem>[] = [];
for (const child of section.children) {
if (isGroupChild(child)) {
const items = child.items.filter(matches);
if (items.length > 0) {
children.push({ ...child, items });
}
} else if (matches(child)) {
children.push(child);
}
}
if (children.length > 0) {
result.push({ ...section, children } as TSection);
}
}
return result;
}