From 875bc426100f0000593fab0f92fcdae79762c8b6 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:09:51 +0100 Subject: [PATCH] feat: browse label-filtered pages without a query Selecting labels now lists their pages newest-first before any text is typed, on both the Postgres and Typesense drivers. --- .../search/components/search-spotlight.tsx | 14 ++++--- .../search/hooks/use-unified-search.ts | 4 +- apps/server/src/core/search/dto/search.dto.ts | 4 +- apps/server/src/core/search/search.service.ts | 37 +++++++++++-------- apps/server/src/ee | 2 +- 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index ee16f08b5..569c85b20 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -106,6 +106,8 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } }, [aiSearchError, t]); + const isLabelBrowse = (filters.labelIds?.length ?? 0) > 0; + // Determine result type for rendering const isAttachmentSearch = filters.contentType === "attachment" && hasAttachmentIndexing; @@ -227,17 +229,19 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { ) : ( <> - {query.length === 0 && resultItems.length === 0 && ( + {query.length === 0 && !isLabelBrowse && resultItems.length === 0 && ( {t("Start typing to search...")} )} - {query.length > 0 && !isFetching && resultItems.length === 0 && ( - {t("No results found...")} - )} + {(query.length > 0 || isLabelBrowse) && + !isFetching && + resultItems.length === 0 && ( + {t("No results found...")} + )} {resultItems.length > 0 && <>{resultItems}} - {query.length > 0 && isFetching && ( + {(query.length > 0 || isLabelBrowse) && isFetching && ( {t("Searching...")} diff --git a/apps/client/src/features/search/hooks/use-unified-search.ts b/apps/client/src/features/search/hooks/use-unified-search.ts index 156c711d8..500b2fd60 100644 --- a/apps/client/src/features/search/hooks/use-unified-search.ts +++ b/apps/client/src/features/search/hooks/use-unified-search.ts @@ -39,11 +39,11 @@ export function useUnifiedSearch( return await searchPage(backendParams); } }, - enabled: !!params.query && enabled, + enabled: (!!params.query || (params.labelIds?.length ?? 0) > 0) && enabled, // keep previous results only within the same search type; page results // rendered as attachments (or vice versa) crash on missing fields placeholderData: (previousData, previousQuery) => { - if (params.query.length < 1) return undefined; + if (!params.query && !params.labelIds?.length) return undefined; if (previousQuery && previousQuery.queryKey[1] !== searchType) { return undefined; } diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index d66bc9fd8..89fb1b289 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -9,9 +9,9 @@ import { } from 'class-validator'; export class SearchDTO { - @IsNotEmpty() + @IsOptional() @IsString() - query: string; + query?: string; @IsOptional() @IsUUID() diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index ceb1468d3..4a0aa2e6e 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -29,24 +29,28 @@ export class SearchService { workspaceId: string; }, ): Promise<{ items: SearchResponseDto[] }> { - const { query } = searchParams; + const query = searchParams.query?.trim() ?? ''; + const labelIds = [...new Set(searchParams.labelIds ?? [])]; + // selected labels are browsable without a query + const browseByLabels = query.length < 1 && labelIds.length > 0; - if (query.length < 1) { + if (query.length < 1 && !browseByLabels) { return { items: [] }; } - const searchQuery = tsquery(query.trim() + '*'); - const labelIds = [...new Set(searchParams.labelIds ?? [])]; + const searchQuery = tsquery(query + '*'); const titleOnly = searchParams.titleOnly === true; - const titleQuery = query.trim(); + const titleQuery = query; - const rankColumn = titleOnly - ? sql`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as( - 'rank', - ) - : sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( - 'rank', - ); - const highlightColumn = titleOnly + const rankColumn = browseByLabels + ? sql`0`.as('rank') + : titleOnly + ? sql`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as( + 'rank', + ) + : sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( + 'rank', + ); + const highlightColumn = browseByLabels || titleOnly ? sql`''`.as('highlight') : sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( 'highlight', @@ -66,14 +70,14 @@ export class SearchService { rankColumn, highlightColumn, ]) - .$if(!titleOnly, (qb) => + .$if(!browseByLabels && !titleOnly, (qb) => qb.where( 'tsv', '@@', sql`to_tsquery('english', f_unaccent(${searchQuery}))`, ), ) - .$if(titleOnly, (qb) => + .$if(!browseByLabels && titleOnly, (qb) => qb.where((eb) => eb( sql`lower(f_unaccent(pages.title))`, @@ -102,7 +106,8 @@ export class SearchService { ), ) .where('deletedAt', 'is', null) - .orderBy('rank', 'desc') + .$if(browseByLabels, (qb) => qb.orderBy('updatedAt', 'desc')) + .$if(!browseByLabels, (qb) => qb.orderBy('rank', 'desc')) .limit(searchParams.limit || 25) .offset(searchParams.offset || 0); diff --git a/apps/server/src/ee b/apps/server/src/ee index 3c198edfa..70976d963 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 3c198edfa02569393b954a2a77b9d1e4c1f566db +Subproject commit 70976d963201cd4e85a8d59dce21edb3e3d287ad