From 1041abbfd6b745e27fb1ca6c15247d4857b952ee Mon Sep 17 00:00:00 2001 From: Bobby Quantum Date: Thu, 3 Sep 2026 22:33:43 +0100 Subject: [PATCH 1/2] feat(tags): browse tagged elements via project search Clicking a tag's element count in the Tags settings tab used to open only the first tagged element and toast that others exist. It now opens the project search dialog in browse mode with that tag pre-selected and the filter panel expanded, so every element with the tag is listed at once. - ProjectSearchService.open() accepts optional initial data (tagIds) - Search dialog seeds its tag filter and panel state from MAT_DIALOG_DATA - Remove the now-unused openedWithMore / taggedElementsNotFound strings - Mark the 'tag filtering in project tree' roadmap item as covered by search browse mode instead of adding a redundant sidebar filter --- README.md | 2 +- frontend/public/assets/i18n/en/tags.json | 4 +- .../tags-tab/tags-tab.component.spec.ts | 59 +++++++------------ .../components/tags-tab/tags-tab.component.ts | 35 ++--------- .../project-search-dialog.component.spec.ts | 52 +++++++++++++++- .../project-search-dialog.component.ts | 17 ++++-- .../core/project-search.service.spec.ts | 16 +++++ .../services/core/project-search.service.ts | 14 ++++- 8 files changed, 120 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index eb275bf21..b212cd028 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Then open `http://localhost:8333` in your browser. | ✅ | 🔥 | Project-wide search (Ctrl/Cmd + Shift + F) | Full-text search across all documents | | ✅ | 🔥 | Find and replace in document | | | ✅ | 🟡 | Tag filtering in search | Filter search results by tags, element types, relationships, and worldbuilding schemas | -| ⬜ | 🟡 | Tag filtering in project tree | Show/hide elements by tag | +| ✅ | 🟡 | Browse elements by tag | Project search browse mode filters by tag; open from the Tags settings tab or the search dialog | | ✅ | 🟡 | Breadcrumbs | Folder path shown above each editor (document, folder, worldbuilding, canvas, relationship chart, timeline); toggle in user settings | | ✅ | 🟡 | Pinning | Pin elements to Home tab and sidebar for quick access | | ✅ | 🟢 | Recent files list | Tracks last 10 files per project | diff --git a/frontend/public/assets/i18n/en/tags.json b/frontend/public/assets/i18n/en/tags.json index 51fc0ac03..5136427ee 100644 --- a/frontend/public/assets/i18n/en/tags.json +++ b/frontend/public/assets/i18n/en/tags.json @@ -24,9 +24,7 @@ "deleteMessage": "Are you sure you want to delete \"{{name}}\"? This will remove the tag from {{count}} element(s).", "deleted": "Deleted tag \"{{name}}\"", "deleteFailed": "Failed to delete tag", - "noElementsWithTag": "No elements have this tag", - "taggedElementsNotFound": "Tagged elements not found", - "openedWithMore": "Opened \"{{name}}\". {{count}} more element(s) also have this tag." + "noElementsWithTag": "No elements have this tag" }, "chipList": { "ariaLabel": "Tag selection", diff --git a/frontend/src/app/components/tags-tab/tags-tab.component.spec.ts b/frontend/src/app/components/tags-tab/tags-tab.component.spec.ts index e0e3ac18e..e620a51f6 100644 --- a/frontend/src/app/components/tags-tab/tags-tab.component.spec.ts +++ b/frontend/src/app/components/tags-tab/tags-tab.component.spec.ts @@ -5,6 +5,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { type TagDefinition, type TagIndexEntry } from '@models/tag.model'; import { DialogGatewayService } from '@services/core/dialog-gateway.service'; +import { ProjectSearchService } from '@services/core/project-search.service'; import { ProjectStateService } from '@services/project/project-state.service'; import { TagService } from '@services/tag/tag.service'; import { of } from 'rxjs'; @@ -21,6 +22,7 @@ describe('TagsTabComponent', () => { let mockSnackBar: Partial; let mockDialog: Partial; let mockDialogGateway: Partial; + let mockProjectSearch: Partial; const mockTags: TagDefinition[] = [ { @@ -92,6 +94,10 @@ describe('TagsTabComponent', () => { openConfirmationDialog: vi.fn().mockResolvedValue(true), }; + mockProjectSearch = { + open: vi.fn(), + }; + await TestBed.configureTestingModule({ imports: [translocoTestProvider(), TagsTabComponent, FormsModule], providers: [ @@ -101,6 +107,7 @@ describe('TagsTabComponent', () => { { provide: MatSnackBar, useValue: mockSnackBar }, { provide: MatDialog, useValue: mockDialog }, { provide: DialogGatewayService, useValue: mockDialogGateway }, + { provide: ProjectSearchService, useValue: mockProjectSearch }, ], }).compileComponents(); @@ -222,9 +229,9 @@ describe('TagsTabComponent', () => { ); }); - it('should open first tagged element and show count message when tag has multiple elements', () => { + it('should open project search with the tag pre-selected', () => { const tag = { - id: '1', + id: 'tag-1', name: 'Test', icon: 'star', color: '#FFF', @@ -232,48 +239,22 @@ describe('TagsTabComponent', () => { elementIds: ['a', 'b', 'c'], }; component.viewTaggedElements(tag); - expect(mockProjectState.openDocument).toHaveBeenCalledWith( - expect.objectContaining({ id: 'a', name: 'Element A' }) - ); - expect(mockSnackBar.open).toHaveBeenCalledWith( - 'Opened "Element A". 2 more element(s) also have this tag.', - 'Dismiss', - { duration: 4000 } - ); - }); - - it('should open single tagged element without extra message', () => { - const tag = { - id: '1', - name: 'Single', - icon: 'star', - color: '#FFF', - count: 1, - elementIds: ['a'], - }; - component.viewTaggedElements(tag); - expect(mockProjectState.openDocument).toHaveBeenCalledWith( - expect.objectContaining({ id: 'a', name: 'Element A' }) - ); + expect(mockProjectSearch.open).toHaveBeenCalledWith({ + tagIds: ['tag-1'], + }); expect(mockSnackBar.open).not.toHaveBeenCalled(); }); - it('should show message when tagged elements are not found in project', () => { - const tag = { - id: '1', - name: 'Orphan', + it('should not open project search when tag has no elements', () => { + component.viewTaggedElements({ + id: 'tag-1', + name: 'Empty', icon: 'star', color: '#FFF', - count: 2, - elementIds: ['nonexistent-1', 'nonexistent-2'], - }; - component.viewTaggedElements(tag); - expect(mockProjectState.openDocument).not.toHaveBeenCalled(); - expect(mockSnackBar.open).toHaveBeenCalledWith( - 'Tagged elements not found', - 'Dismiss', - { duration: 3000 } - ); + count: 0, + elementIds: [], + }); + expect(mockProjectSearch.open).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/app/components/tags-tab/tags-tab.component.ts b/frontend/src/app/components/tags-tab/tags-tab.component.ts index c5fb4fcde..bc4a96379 100644 --- a/frontend/src/app/components/tags-tab/tags-tab.component.ts +++ b/frontend/src/app/components/tags-tab/tags-tab.component.ts @@ -19,10 +19,10 @@ import { TagEditDialogComponent, type TagEditDialogResult, } from '@dialogs/tag-edit-dialog/tag-edit-dialog.component'; -import { type Element } from '@inkweld/index'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; import { type TagIndexEntry } from '@models/tag.model'; import { DialogGatewayService } from '@services/core/dialog-gateway.service'; +import { ProjectSearchService } from '@services/core/project-search.service'; import { ProjectStateService } from '@services/project/project-state.service'; import { TagService } from '@services/tag/tag.service'; import { firstValueFrom } from 'rxjs'; @@ -62,6 +62,7 @@ interface TagView { export class TagsTabComponent { private readonly projectState = inject(ProjectStateService); private readonly tagService = inject(TagService); + private readonly projectSearchService = inject(ProjectSearchService); private readonly snackBar = inject(MatSnackBar); private readonly transloco = inject(TranslocoService); private readonly dialogGateway = inject(DialogGatewayService); @@ -266,7 +267,8 @@ export class TagsTabComponent { } /** - * Navigate to an element with this tag + * Browse every element carrying this tag in the project search dialog + * (browse mode with the tag filter pre-selected). */ viewTaggedElements(tag: TagView): void { if (tag.count === 0) { @@ -278,34 +280,7 @@ export class TagsTabComponent { return; } - // Resolve element IDs to project elements and open the first one - const allElements = this.projectState.elements(); - const taggedElements = tag.elementIds - .map(id => allElements.find(e => e.id === id)) - .filter((e): e is Element => e !== undefined); - - if (taggedElements.length === 0) { - this.snackBar.open( - this.transloco.translate('tags.tab.taggedElementsNotFound'), - this.transloco.translate('snackbar.dismiss'), - { duration: 3000 } - ); - return; - } - - // Open the first tagged element - this.projectState.openDocument(taggedElements[0]); - - if (taggedElements.length > 1) { - this.snackBar.open( - this.transloco.translate('tags.tab.openedWithMore', { - name: taggedElements[0].name, - count: taggedElements.length - 1, - }), - this.transloco.translate('snackbar.dismiss'), - { duration: 4000 } - ); - } + this.projectSearchService.open({ tagIds: [tag.id] }); } /** diff --git a/frontend/src/app/dialogs/project-search-dialog/project-search-dialog.component.spec.ts b/frontend/src/app/dialogs/project-search-dialog/project-search-dialog.component.spec.ts index d4afc76fd..d3fae7a07 100644 --- a/frontend/src/app/dialogs/project-search-dialog/project-search-dialog.component.spec.ts +++ b/frontend/src/app/dialogs/project-search-dialog/project-search-dialog.component.spec.ts @@ -1,6 +1,10 @@ import { provideZonelessChangeDetection, signal } from '@angular/core'; import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; import { type Element, ElementType } from '@inkweld/index'; import { type MockedObject, vi } from 'vitest'; @@ -313,6 +317,52 @@ describe('ProjectSearchDialogComponent', () => { }); }); + describe('pre-selected tags from dialog data', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + translocoTestProvider(), + ProjectSearchDialogComponent, + MatDialogModule, + ], + providers: [ + provideZonelessChangeDetection(), + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: { tagIds: ['tag-1'] } }, + { + provide: ProjectSearchService, + useValue: mockProjectSearchService, + }, + { provide: ProjectStateService, useValue: mockProjectState }, + { provide: FindInDocumentService, useValue: mockFindInDocument }, + { provide: TagService, useValue: mockTagService }, + { provide: RelationshipService, useValue: mockRelationshipService }, + { provide: WorldbuildingService, useValue: mockWorldbuildingService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectSearchDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should start with the given tags selected and the filter panel open', () => { + expect(component.selectedTagIds()).toEqual(['tag-1']); + expect(component.showFilters()).toBe(true); + expect(component.isTagSelected('tag-1')).toBe(true); + }); + + it('should run the initial browse with the tag filter applied', () => { + expect(mockProjectSearchService.search).toHaveBeenCalledWith( + '', + expect.any(Function), + expect.any(AbortSignal), + expect.objectContaining({ tagIds: ['tag-1'] }) + ); + }); + }); + describe('filters', () => { describe('toggleFilters', () => { it('should toggle showFilters signal', () => { diff --git a/frontend/src/app/dialogs/project-search-dialog/project-search-dialog.component.ts b/frontend/src/app/dialogs/project-search-dialog/project-search-dialog.component.ts index 0fc9767c0..131aefc19 100644 --- a/frontend/src/app/dialogs/project-search-dialog/project-search-dialog.component.ts +++ b/frontend/src/app/dialogs/project-search-dialog/project-search-dialog.component.ts @@ -11,7 +11,11 @@ import { ViewChild, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -20,6 +24,7 @@ import { TranslocoModule } from '@jsverse/transloco'; import { FindInDocumentService } from '../../services/core/find-in-document.service'; import { + type ProjectSearchDialogData, type ProjectSearchFilters, type ProjectSearchProgress, type ProjectSearchResult, @@ -65,6 +70,10 @@ export class ProjectSearchDialogComponent implements AfterViewInit, OnDestroy { private readonly dialogRef = inject( MatDialogRef ); + private readonly dialogData = inject( + MAT_DIALOG_DATA, + { optional: true } + ); private readonly projectSearchService = inject(ProjectSearchService); private readonly projectState = inject(ProjectStateService); private readonly findInDocumentService = inject(FindInDocumentService); @@ -111,11 +120,11 @@ export class ProjectSearchDialogComponent implements AfterViewInit, OnDestroy { // ─── Filters ────────────────────────────────────────────────────────── - /** Whether the filter panel is expanded */ - readonly showFilters = signal(false); + /** Whether the filter panel is expanded (open when tags were pre-selected) */ + readonly showFilters = signal((this.dialogData?.tagIds?.length ?? 0) > 0); /** Selected tag IDs for filtering */ - readonly selectedTagIds = signal([]); + readonly selectedTagIds = signal(this.dialogData?.tagIds ?? []); /** Selected element types for filtering */ readonly selectedElementTypes = signal([]); diff --git a/frontend/src/app/services/core/project-search.service.spec.ts b/frontend/src/app/services/core/project-search.service.spec.ts index 317f78cb6..a01559ddb 100644 --- a/frontend/src/app/services/core/project-search.service.spec.ts +++ b/frontend/src/app/services/core/project-search.service.spec.ts @@ -267,6 +267,22 @@ describe('ProjectSearchService', () => { expect(service.isOpen()).toBe(true); }); + it('should pass pre-selected tag IDs to the dialog as data', () => { + service.open({ tagIds: ['tag-1'] }); + expect(mockDialog.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ data: { tagIds: ['tag-1'] } }) + ); + }); + + it('should pass empty data when opened without options', () => { + service.open(); + expect(mockDialog.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ data: {} }) + ); + }); + it('should not open the dialog if it is already open', () => { service.open(); service.open(); diff --git a/frontend/src/app/services/core/project-search.service.ts b/frontend/src/app/services/core/project-search.service.ts index 8befc2511..adf507456 100644 --- a/frontend/src/app/services/core/project-search.service.ts +++ b/frontend/src/app/services/core/project-search.service.ts @@ -62,6 +62,14 @@ export interface ProjectSearchFilters { schemaIds?: string[]; } +/** + * Initial state passed to the project search dialog when opening it. + */ +export interface ProjectSearchDialogData { + /** Tag IDs to pre-select in the tag filter */ + tagIds?: string[]; +} + /** Context characters to show around each match */ const SNIPPET_CONTEXT = 60; @@ -130,12 +138,16 @@ export class ProjectSearchService { /** * Open the project search dialog. + * + * @param options Optional initial state, e.g. tags to pre-select so the + * dialog opens in browse mode showing every element with those tags. */ - open(): void { + open(options?: ProjectSearchDialogData): void { if (this.isOpen()) return; this.isOpen.set(true); this.dialogRef = this.dialog.open(ProjectSearchDialogComponent, { + data: options ?? {}, width: '680px', maxWidth: '92vw', maxHeight: '85vh', From 4517b065038fb83f73d76089033b2366914d72bc Mon Sep 17 00:00:00 2001 From: Bobby Quantum Date: Thu, 3 Sep 2026 22:51:57 +0100 Subject: [PATCH 2/2] fix(tags): keep not-found feedback when tagged elements no longer exist Tag assignments can outlive their elements, so a tag with a non-zero count may resolve to no existing elements. Check against the current element list before opening project search and show the not-found snackbar instead. --- frontend/public/assets/i18n/en/tags.json | 3 ++- .../tags-tab/tags-tab.component.spec.ts | 17 +++++++++++++++++ .../components/tags-tab/tags-tab.component.ts | 12 ++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/public/assets/i18n/en/tags.json b/frontend/public/assets/i18n/en/tags.json index 5136427ee..fd72cd7f5 100644 --- a/frontend/public/assets/i18n/en/tags.json +++ b/frontend/public/assets/i18n/en/tags.json @@ -24,7 +24,8 @@ "deleteMessage": "Are you sure you want to delete \"{{name}}\"? This will remove the tag from {{count}} element(s).", "deleted": "Deleted tag \"{{name}}\"", "deleteFailed": "Failed to delete tag", - "noElementsWithTag": "No elements have this tag" + "noElementsWithTag": "No elements have this tag", + "taggedElementsNotFound": "Tagged elements not found" }, "chipList": { "ariaLabel": "Tag selection", diff --git a/frontend/src/app/components/tags-tab/tags-tab.component.spec.ts b/frontend/src/app/components/tags-tab/tags-tab.component.spec.ts index e620a51f6..cfa4e15cd 100644 --- a/frontend/src/app/components/tags-tab/tags-tab.component.spec.ts +++ b/frontend/src/app/components/tags-tab/tags-tab.component.spec.ts @@ -245,6 +245,23 @@ describe('TagsTabComponent', () => { expect(mockSnackBar.open).not.toHaveBeenCalled(); }); + it('should show not-found message instead of opening search when tagged elements no longer exist', () => { + component.viewTaggedElements({ + id: 'tag-1', + name: 'Orphan', + icon: 'star', + color: '#FFF', + count: 2, + elementIds: ['nonexistent-1', 'nonexistent-2'], + }); + expect(mockProjectSearch.open).not.toHaveBeenCalled(); + expect(mockSnackBar.open).toHaveBeenCalledWith( + 'Tagged elements not found', + 'Dismiss', + { duration: 3000 } + ); + }); + it('should not open project search when tag has no elements', () => { component.viewTaggedElements({ id: 'tag-1', diff --git a/frontend/src/app/components/tags-tab/tags-tab.component.ts b/frontend/src/app/components/tags-tab/tags-tab.component.ts index bc4a96379..90e7594b4 100644 --- a/frontend/src/app/components/tags-tab/tags-tab.component.ts +++ b/frontend/src/app/components/tags-tab/tags-tab.component.ts @@ -280,6 +280,18 @@ export class TagsTabComponent { return; } + // Tag assignments can outlive their elements, so make sure at least one + // tagged element still exists before opening an empty search result. + const existingIds = new Set(this.projectState.elements().map(e => e.id)); + if (!tag.elementIds.some(id => existingIds.has(id))) { + this.snackBar.open( + this.transloco.translate('tags.tab.taggedElementsNotFound'), + this.transloco.translate('snackbar.dismiss'), + { duration: 3000 } + ); + return; + } + this.projectSearchService.open({ tagIds: [tag.id] }); }