Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 1 addition & 2 deletions frontend/public/assets/i18n/en/tags.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 29 additions & 31 deletions frontend/src/app/components/tags-tab/tags-tab.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,7 @@ describe('TagsTabComponent', () => {
let mockSnackBar: Partial<MatSnackBar>;
let mockDialog: Partial<MatDialog>;
let mockDialogGateway: Partial<DialogGatewayService>;
let mockProjectSearch: Partial<ProjectSearchService>;

const mockTags: TagDefinition[] = [
{
Expand Down Expand Up @@ -92,6 +94,10 @@ describe('TagsTabComponent', () => {
openConfirmationDialog: vi.fn().mockResolvedValue(true),
};

mockProjectSearch = {
open: vi.fn(),
};

await TestBed.configureTestingModule({
imports: [translocoTestProvider(), TagsTabComponent, FormsModule],
providers: [
Expand All @@ -101,6 +107,7 @@ describe('TagsTabComponent', () => {
{ provide: MatSnackBar, useValue: mockSnackBar },
{ provide: MatDialog, useValue: mockDialog },
{ provide: DialogGatewayService, useValue: mockDialogGateway },
{ provide: ProjectSearchService, useValue: mockProjectSearch },
],
}).compileComponents();

Expand Down Expand Up @@ -222,59 +229,50 @@ 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',
count: 3,
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', () => {
Expand Down
31 changes: 9 additions & 22 deletions frontend/src/app/components/tags-tab/tags-tab.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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'),
Expand All @@ -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] });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -65,6 +70,10 @@ export class ProjectSearchDialogComponent implements AfterViewInit, OnDestroy {
private readonly dialogRef = inject(
MatDialogRef<ProjectSearchDialogComponent>
);
private readonly dialogData = inject<ProjectSearchDialogData | null>(
MAT_DIALOG_DATA,
{ optional: true }
);
private readonly projectSearchService = inject(ProjectSearchService);
private readonly projectState = inject(ProjectStateService);
private readonly findInDocumentService = inject(FindInDocumentService);
Expand Down Expand Up @@ -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<string[]>([]);
readonly selectedTagIds = signal<string[]>(this.dialogData?.tagIds ?? []);

/** Selected element types for filtering */
readonly selectedElementTypes = signal<ElementType[]>([]);
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/app/services/core/project-search.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/app/services/core/project-search.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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',
Expand Down