feat: Document tagging system#11882
Conversation
There was a problem hiding this comment.
Pull request overview
Implements a new end-to-end document tagging feature across the database, server API, search filtering, and client UI/navigation for Outline.
Changes:
- Add
tagsanddocument_tagsmodels + migration, and wire up policies/presenters/events. - Introduce
tags.*API endpoints and integrate tag filters intodocuments.search/documents.search_titlesviatagIds. - Add client-side tag UX (document tag editor,
/tagspage, sidebar link) and#tagsearch token parsing.
Reviewed changes
Copilot reviewed 39 out of 40 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| server/types.ts | Adds TagEvent to the event union for tag-related activity. |
| server/routes/api/tags/tags.ts | Implements tag CRUD + tag/document association endpoints. |
| server/routes/api/tags/tags.test.ts | Adds server API tests for tags endpoints and authorization/isolation. |
| server/routes/api/tags/schema.ts | Adds zod schemas for all tag endpoints. |
| server/routes/api/tags/index.ts | Exposes the tags router. |
| server/routes/api/index.ts | Registers tags routes in the main API router. |
| server/routes/api/documents/schema.ts | Adds tagIds filters and relaxes search_titles query validation. |
| server/routes/api/documents/documents.ts | Injects tags into documents.info and passes tagIds into search helpers. |
| server/routes/api/documents/documents.test.ts | Adds tests for documents.info tags and documents.search tag filtering. |
| server/presenters/tag.ts | Adds a presenter for tag API payloads (incl. documentCount). |
| server/presenters/index.ts | Exports presentTag. |
| server/policies/tag.ts | Adds tag policies (read/update/delete/createTag). |
| server/policies/index.ts | Registers tag policy module. |
| server/models/index.ts | Exports Tag and DocumentTag models. |
| server/models/helpers/SearchHelper.ts | Adds AND-semantics tag filtering via EXISTS subqueries. |
| server/models/Tag.ts | Introduces the Tag model with name normalization hook and associations. |
| server/models/DocumentTag.ts | Introduces the join model between documents and tags. |
| server/models/Document.ts | Adds documentTags association on Document. |
| server/migrations/20260317213539-create-tags.js | Creates tags + document_tags tables and relevant indexes/FKs. |
| plugins/webhooks/server/tasks/DeliverWebhookTask.ts | Adds tag event names to the ignored webhook switch-case. |
| app/utils/routeHelpers.ts | Adds tagsPath() route helper. |
| app/utils/parseSearchQuery.ts | Adds #tag token extraction/normalization for search queries. |
| app/utils/parseSearchQuery.test.ts | Unit tests for tag token parsing behavior. |
| app/stores/TagsStore.ts | Adds TagsStore with name cache and tag CRUD/association RPCs. |
| app/stores/TagsStore.test.ts | Unit tests for name cache, ordering, and clear behavior. |
| app/stores/RootStore.ts | Registers TagsStore in the root store. |
| app/stores/DocumentsStore.ts | Hydrates document.tags from API payload into Tag models. |
| app/scenes/Tags/index.tsx | Adds /tags scene listing workspace tags. |
| app/scenes/Search/components/SearchInput.tsx | Disables browser autocomplete for search input. |
| app/scenes/Search/Search.tsx | Parses #tag tokens and applies tagIds filters to search requests. |
| app/scenes/Document/components/Editor.tsx | Adds TagInput under the title for non-shared, non-template docs. |
| app/routes/authenticated.tsx | Adds authenticated route for /tags. |
| app/models/Tag.ts | Introduces Tag client model. |
| app/models/Document.ts | Adds tags observable array to Document client model. |
| app/components/TagList.tsx | Adds tag pill list component with optional dismiss action. |
| app/components/TagInput.tsx | Adds inline tag editor with autocomplete and create/add/remove behaviors. |
| app/components/Sidebar/components/TagsLink.tsx | Adds sidebar link to the tags index. |
| app/components/Sidebar/App.tsx | Renders the new Tags link in the sidebar. |
| .gitignore | Ignores .worktrees/. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 40 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 40 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 40 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…fore remove event
- TagInput force-fetches document when tags not populated (cache hit fix) - TagInput updates document.tags observable immediately after add/remove - Search page fetches all tags on mount so #tag queries resolve tag IDs
- Made query optional (was required non-empty) to allow pure tag searches - Added tagIds field to DocumentsSearchTitlesSchema - Pass tagIds through to SearchHelper.searchTitlesForUser in route handler - SearchHelper.buildWhere already handled tagIds, no changes needed there
With 'search titles only' on and a pure #tag query (no text), cleanQuery is empty — consistent with how an empty plain-text search returns nothing.
- Arrow keys navigate suggestions and update the input with the full tag name - Mouse hover highlights suggestion and fills the input - Leaving hover restores the typed text - Enter selects the keyboard-highlighted item, then the first suggestion, then creates a new tag (only if no suggestions match) - Blur restores the typed text if the dropdown closes without selection
Partial tag tokens like #eng that don't resolve to a tag ID were being treated as an empty filter, returning all documents. Now returns an empty result set immediately. Also disables browser autocomplete on the search input to prevent stale partial tag tokens from appearing as native suggestions.
When clicking a dropdown suggestion, onBlur fired before handleAddTag's async operations completed, causing the onBlur timeout (150ms) to restore typedValue to the input. Prevent default on onMouseDown so the input retains focus and no blur/restore cycle occurs.
- tags API: cross-team isolation, cascade delete, documentCount accuracy, viewer auth on add/remove, cross-team boundary on add - parseSearchQuery: lone hash, hyphens in names, deduplication - TagsStore: clear() empties name cache, duplicate add doesn't corrupt cache - documents.info: response includes tags array Also fixes two bugs found by the new tests: - parseSearchQuery regex now matches tag names containing hyphens - parseSearchQuery deduplicates repeated tag tokens
- Handle UniqueConstraintError in tags.update with proper ValidationError - Only fire tags.remove event when a row is actually destroyed - Use instance destroy instead of static destroy in tags.remove - Clean up documentCount type cast in tags.list - Apply Prettier formatting to all tags feature files
… correctness, tag resolution)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 40 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ArrowDown was capped at suggestions.length-1, making the rendered "Create" row unreachable by keyboard. Enter with no highlighted item always selected suggestions[0] instead of using the typed value. Now ArrowDown reaches index suggestions.length (the Create row), ArrowUp navigates back from it, and Enter prefers the typed value unless an existing suggestion was explicitly highlighted.
|
Basically the most important (at least for me) feature for Outline right now. I hope it will be merged to main soon! |

Summary
Implements a full document tagging feature for Outline, as requested in #765.
tags(name + team scope, unique index) anddocument_tags(join table with cascade deletes)tags.create(upsert by name),tags.list(with document counts),tags.update,tags.delete(admin-only),tags.add,tags.remove#tagtokens in the search bar are parsed client-side, resolved to tag IDs, and passed astagIdsfilters todocuments.searchanddocuments.search_titles(AND semantics)/tagsindex page in sidebar, tag cards link to searchDetails
documents.infoeagerly loads tags; list endpoints skip tags to avoid N+1EXISTSsubqueries withsequelize.escape()for tag filteringTest plan
yarn tsc --noEmitpasses with 0 errors#tagtokens, persistence across reloadCloses #765