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..fd72cd7f5 100644 --- a/frontend/public/assets/i18n/en/tags.json +++ b/frontend/public/assets/i18n/en/tags.json @@ -25,8 +25,7 @@ "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." + "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 e0e3ac18e..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 @@ -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,49 +239,40 @@ 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', + 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'], - }; - component.viewTaggedElements(tag); - expect(mockProjectState.openDocument).not.toHaveBeenCalled(); + }); + 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', + name: 'Empty', + icon: 'star', + color: '#FFF', + count: 0, + elementIds: [], + }); + expect(mockProjectSearch.open).not.toHaveBeenCalled(); + }); }); describe('loadTags', () => { 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..90e7594b4 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,13 +280,10 @@ 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) { + // 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'), @@ -293,19 +292,7 @@ export class TagsTabComponent { 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',