Fix universal search (#445)

* Fix tags not included in views

* fixup! Fix tags not included in views

* Show toaster when creating view

* Add more translations

* Fix test issues
This commit is contained in:
Chris 2025-10-23 21:43:42 +03:00 committed by GitHub
parent c6407570c7
commit 48ee54a12f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 567 additions and 3 deletions

View file

@ -0,0 +1,22 @@
'use strict';
const { safeAddColumns } = require('../utils/migration-utils');
module.exports = {
up: async (queryInterface, Sequelize) => {
await safeAddColumns(queryInterface, 'views', [
{
name: 'tags',
definition: {
type: Sequelize.TEXT,
allowNull: true,
comment: 'JSON array of tag names for filtering',
},
},
]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('views', 'tags');
},
};

View file

@ -51,6 +51,17 @@ module.exports = (sequelize) => {
type: DataTypes.STRING,
allowNull: true,
},
tags: {
type: DataTypes.TEXT,
allowNull: true,
get() {
const rawValue = this.getDataValue('tags');
return rawValue ? JSON.parse(rawValue) : [];
},
set(value) {
this.setDataValue('tags', JSON.stringify(value));
},
},
is_pinned: {
type: DataTypes.BOOLEAN,
allowNull: false,

View file

@ -64,7 +64,7 @@ router.get('/:identifier', async (req, res) => {
// POST /api/views - Create a new view
router.post('/', async (req, res) => {
try {
const { name, search_query, filters, priority, due } = req.body;
const { name, search_query, filters, priority, due, tags } = req.body;
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'View name is required' });
@ -77,6 +77,7 @@ router.post('/', async (req, res) => {
filters: filters || [],
priority: priority || null,
due: due || null,
tags: tags || [],
is_pinned: false,
});
@ -105,7 +106,7 @@ router.patch('/:identifier', async (req, res) => {
return res.status(404).json({ error: 'View not found' });
}
const { name, search_query, filters, priority, due, is_pinned } =
const { name, search_query, filters, priority, due, tags, is_pinned } =
req.body;
const updates = {};
@ -114,6 +115,7 @@ router.patch('/:identifier', async (req, res) => {
if (filters !== undefined) updates.filters = filters;
if (priority !== undefined) updates.priority = priority;
if (due !== undefined) updates.due = due;
if (tags !== undefined) updates.tags = tags;
if (is_pinned !== undefined) updates.is_pinned = is_pinned;
await view.update(updates);

View file

@ -0,0 +1,479 @@
const request = require('supertest');
const app = require('../../app');
const { View, Task, Tag, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Views Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'views-test@example.com',
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'views-test@example.com',
password: 'password123',
});
});
describe('POST /api/views', () => {
it('should create a view without tags', async () => {
const response = await agent.post('/api/views').send({
name: 'My Tasks',
search_query: 'important',
filters: ['Task'],
priority: 'high',
due: null,
tags: null,
});
expect(response.status).toBe(201);
expect(response.body.name).toBe('My Tasks');
expect(response.body.search_query).toBe('important');
expect(response.body.filters).toEqual(['Task']);
expect(response.body.priority).toBe('high');
expect(response.body.tags).toEqual([]);
expect(response.body.uid).toBeDefined();
});
it('should create a view with single tag', async () => {
const response = await agent.post('/api/views').send({
name: 'Work Tasks',
search_query: null,
filters: ['Task'],
priority: null,
due: null,
tags: ['work'],
});
expect(response.status).toBe(201);
expect(response.body.name).toBe('Work Tasks');
expect(response.body.tags).toEqual(['work']);
});
it('should create a view with multiple tags', async () => {
const response = await agent.post('/api/views').send({
name: 'Urgent Work Tasks',
search_query: null,
filters: ['Task'],
priority: 'high',
due: null,
tags: ['work', 'urgent'],
});
expect(response.status).toBe(201);
expect(response.body.name).toBe('Urgent Work Tasks');
expect(response.body.tags).toEqual(['work', 'urgent']);
});
it('should create a view with all filters including tags', async () => {
const response = await agent.post('/api/views').send({
name: 'Comprehensive View',
search_query: 'meeting',
filters: ['Task', 'Project'],
priority: 'high',
due: 'today',
tags: ['work', 'important'],
});
expect(response.status).toBe(201);
expect(response.body.name).toBe('Comprehensive View');
expect(response.body.search_query).toBe('meeting');
expect(response.body.filters).toEqual(['Task', 'Project']);
expect(response.body.priority).toBe('high');
expect(response.body.due).toBe('today');
expect(response.body.tags).toEqual(['work', 'important']);
});
it('should require view name', async () => {
const response = await agent.post('/api/views').send({
name: '',
filters: ['Task'],
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('View name is required');
});
it('should handle empty tags array', async () => {
const response = await agent.post('/api/views').send({
name: 'No Tags View',
filters: ['Task'],
tags: [],
});
expect(response.status).toBe(201);
expect(response.body.tags).toEqual([]);
});
});
describe('GET /api/views', () => {
beforeEach(async () => {
await View.create({
user_id: user.id,
name: 'View 1',
filters: ['Task'],
tags: ['work'],
});
await View.create({
user_id: user.id,
name: 'View 2',
filters: ['Project'],
tags: ['personal', 'home'],
});
await View.create({
user_id: user.id,
name: 'Pinned View',
filters: ['Task'],
tags: [],
is_pinned: true,
});
});
it('should retrieve all views for the user', async () => {
const response = await agent.get('/api/views');
expect(response.status).toBe(200);
expect(response.body.length).toBe(3);
});
it('should return views with tags', async () => {
const response = await agent.get('/api/views');
expect(response.status).toBe(200);
const view1 = response.body.find((v) => v.name === 'View 1');
const view2 = response.body.find((v) => v.name === 'View 2');
expect(view1.tags).toEqual(['work']);
expect(view2.tags).toEqual(['personal', 'home']);
});
it('should order pinned views first', async () => {
const response = await agent.get('/api/views');
expect(response.status).toBe(200);
expect(response.body[0].name).toBe('Pinned View');
});
});
describe('GET /api/views/:identifier', () => {
let viewUid;
beforeEach(async () => {
const view = await View.create({
user_id: user.id,
name: 'Tagged View',
filters: ['Task', 'Note'],
priority: 'high',
tags: ['work', 'urgent'],
});
viewUid = view.uid;
});
it('should retrieve a specific view by uid', async () => {
const response = await agent.get(`/api/views/${viewUid}`);
expect(response.status).toBe(200);
expect(response.body.name).toBe('Tagged View');
expect(response.body.filters).toEqual(['Task', 'Note']);
expect(response.body.priority).toBe('high');
expect(response.body.tags).toEqual(['work', 'urgent']);
});
it('should return 404 for non-existent view', async () => {
const response = await agent.get('/api/views/nonexistent-uid');
expect(response.status).toBe(404);
expect(response.body.error).toBe('View not found');
});
});
describe('PATCH /api/views/:identifier', () => {
let viewUid;
beforeEach(async () => {
const view = await View.create({
user_id: user.id,
name: 'Original View',
filters: ['Task'],
tags: ['work'],
});
viewUid = view.uid;
});
it('should update view tags', async () => {
const response = await agent.patch(`/api/views/${viewUid}`).send({
tags: ['work', 'urgent', 'high-priority'],
});
expect(response.status).toBe(200);
expect(response.body.tags).toEqual([
'work',
'urgent',
'high-priority',
]);
});
it('should clear tags when set to empty array', async () => {
const response = await agent.patch(`/api/views/${viewUid}`).send({
tags: [],
});
expect(response.status).toBe(200);
expect(response.body.tags).toEqual([]);
});
it('should update name and tags together', async () => {
const response = await agent.patch(`/api/views/${viewUid}`).send({
name: 'Updated View',
tags: ['personal'],
});
expect(response.status).toBe(200);
expect(response.body.name).toBe('Updated View');
expect(response.body.tags).toEqual(['personal']);
});
it('should update all fields including tags', async () => {
const response = await agent.patch(`/api/views/${viewUid}`).send({
name: 'Fully Updated View',
search_query: 'important',
filters: ['Task', 'Project'],
priority: 'high',
due: 'today',
tags: ['work', 'urgent'],
is_pinned: true,
});
expect(response.status).toBe(200);
expect(response.body.name).toBe('Fully Updated View');
expect(response.body.search_query).toBe('important');
expect(response.body.filters).toEqual(['Task', 'Project']);
expect(response.body.priority).toBe('high');
expect(response.body.due).toBe('today');
expect(response.body.tags).toEqual(['work', 'urgent']);
expect(response.body.is_pinned).toBe(true);
});
});
describe('DELETE /api/views/:identifier', () => {
let viewUid;
beforeEach(async () => {
const view = await View.create({
user_id: user.id,
name: 'View to Delete',
filters: ['Task'],
tags: ['work'],
});
viewUid = view.uid;
});
it('should delete a view', async () => {
const response = await agent.delete(`/api/views/${viewUid}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('View successfully deleted');
// Verify it's gone
const getResponse = await agent.get(`/api/views/${viewUid}`);
expect(getResponse.status).toBe(404);
});
it('should return 404 for non-existent view', async () => {
const response = await agent.delete('/api/views/nonexistent-uid');
expect(response.status).toBe(404);
expect(response.body.error).toBe('View not found');
});
});
describe('Views with Tag Filtering Integration', () => {
let workTag, urgentTag, personalTag;
beforeEach(async () => {
// Create tags
workTag = await Tag.create({
user_id: user.id,
name: 'work',
});
urgentTag = await Tag.create({
user_id: user.id,
name: 'urgent',
});
personalTag = await Tag.create({
user_id: user.id,
name: 'personal',
});
// Create tasks with tags
const task1 = await Task.create({
user_id: user.id,
name: 'Work task 1',
status: 0,
});
await task1.addTag(workTag);
const task2 = await Task.create({
user_id: user.id,
name: 'Urgent work task',
status: 0,
});
await task2.addTag(workTag);
await task2.addTag(urgentTag);
const task3 = await Task.create({
user_id: user.id,
name: 'Personal task',
status: 0,
});
await task3.addTag(personalTag);
});
it('should create view with tags and retrieve matching results', async () => {
// Create a view with work tag
const createResponse = await agent.post('/api/views').send({
name: 'Work Tasks View',
filters: ['Task'],
tags: ['work'],
});
expect(createResponse.status).toBe(201);
expect(createResponse.body.tags).toEqual(['work']);
// Verify the view is retrievable
const getResponse = await agent.get(
`/api/views/${createResponse.body.uid}`
);
expect(getResponse.status).toBe(200);
expect(getResponse.body.tags).toEqual(['work']);
// Now use search API with the same tags to verify filtering works
const searchResponse = await agent.get('/api/search').query({
tags: 'work',
filters: 'Task',
});
expect(searchResponse.status).toBe(200);
const tasks = searchResponse.body.results.filter(
(r) => r.type === 'Task'
);
expect(tasks.length).toBe(2);
// Both tasks should have 'work' in their name (case-insensitive)
expect(
tasks.every((t) => t.name.toLowerCase().includes('work'))
).toBe(true);
});
it('should save view with multiple tags and retrieve correct results', async () => {
// Create a view with multiple tags
const createResponse = await agent.post('/api/views').send({
name: 'Urgent Work View',
filters: ['Task'],
tags: ['work', 'urgent'],
});
expect(createResponse.status).toBe(201);
expect(createResponse.body.tags).toEqual(['work', 'urgent']);
// Search with same tags
const searchResponse = await agent.get('/api/search').query({
tags: 'work,urgent',
filters: 'Task',
});
expect(searchResponse.status).toBe(200);
const tasks = searchResponse.body.results.filter(
(r) => r.type === 'Task'
);
// Should find tasks with either work OR urgent tag
expect(tasks.length).toBeGreaterThanOrEqual(1);
});
it('should persist tags correctly after update', async () => {
// Create view with one tag
const createResponse = await agent.post('/api/views').send({
name: 'Initial View',
filters: ['Task'],
tags: ['work'],
});
const viewUid = createResponse.body.uid;
// Update to different tags
const updateResponse = await agent
.patch(`/api/views/${viewUid}`)
.send({
tags: ['personal'],
});
expect(updateResponse.status).toBe(200);
expect(updateResponse.body.tags).toEqual(['personal']);
// Retrieve and verify
const getResponse = await agent.get(`/api/views/${viewUid}`);
expect(getResponse.status).toBe(200);
expect(getResponse.body.tags).toEqual(['personal']);
});
});
describe('User Isolation', () => {
let otherUser, otherAgent;
beforeEach(async () => {
// Create another user
otherUser = await createTestUser({
email: 'other-views-user@example.com',
});
otherAgent = request.agent(app);
await otherAgent.post('/api/login').send({
email: 'other-views-user@example.com',
password: 'password123',
});
// Create view for first user
await View.create({
user_id: user.id,
name: 'User 1 View',
filters: ['Task'],
tags: ['work'],
});
// Create view for second user
await View.create({
user_id: otherUser.id,
name: 'User 2 View',
filters: ['Task'],
tags: ['personal'],
});
});
it('should only return views for authenticated user', async () => {
const response = await agent.get('/api/views');
expect(response.status).toBe(200);
expect(response.body.length).toBe(1);
expect(response.body[0].name).toBe('User 1 View');
expect(response.body[0].tags).toEqual(['work']);
});
it('should not allow access to other users views', async () => {
const otherUserViews = await View.findAll({
where: { user_id: otherUser.id },
});
const otherViewUid = otherUserViews[0].uid;
const response = await agent.get(`/api/views/${otherViewUid}`);
expect(response.status).toBe(404);
});
});
});

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
InformationCircleIcon,
BookmarkIcon,
@ -8,6 +9,7 @@ import {
} from '@heroicons/react/24/outline';
import FilterBadge from './FilterBadge';
import SearchResults from './SearchResults';
import { useToast } from '../Shared/ToastContext';
interface SearchMenuProps {
searchQuery: string;
@ -59,6 +61,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
onClose,
}) => {
const { t } = useTranslation();
const { showSuccessToast, showErrorToast } = useToast();
const [selectedPriority, setSelectedPriority] = useState<string | null>(
null
);
@ -137,14 +140,32 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
throw new Error('Failed to save view');
}
const savedView = await response.json();
// Reset form
setViewName('');
setShowSaveForm(false);
setSaveError('');
// Show success toast with link to the view
showSuccessToast(
<div className="flex items-center gap-2">
<span>View saved successfully!</span>
<Link
to={`/views/${savedView.uid}`}
className="underline font-semibold hover:text-green-100"
onClick={onClose}
>
View now
</Link>
</div>
);
// Notify sidebar to refresh
window.dispatchEvent(new CustomEvent('viewUpdated'));
} catch (err) {
setSaveError(t('search.failedToSave'));
showErrorToast('Failed to save view. Please try again.');
console.error('Error saving view:', err);
} finally {
setIsSaving(false);

View file

@ -26,6 +26,7 @@ interface View {
filters: string[];
priority: string | null;
due: string | null;
tags: string[];
is_pinned: boolean;
}
@ -115,6 +116,10 @@ const ViewDetail: React.FC = () => {
filters: viewData.filters,
priority: viewData.priority || undefined,
due: viewData.due || undefined,
tags:
viewData.tags && viewData.tags.length > 0
? viewData.tags
: undefined,
});
// Separate results by type
@ -432,10 +437,34 @@ const ViewDetail: React.FC = () => {
</span>
</div>
)}
{view.tags &&
view.tags.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
{t('views.tags')}
</p>
<div className="flex flex-wrap gap-2">
{view.tags.map(
(tag) => (
<span
key={
tag
}
className="px-2 py-1 bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200 rounded text-xs font-medium"
>
{tag}
</span>
)
)}
</div>
</div>
)}
{!view.filters.length &&
!view.search_query &&
!view.priority &&
!view.due && (
!view.due &&
(!view.tags ||
view.tags.length === 0) && (
<p className="text-sm text-gray-600 dark:text-gray-400 italic">
{t(
'views.noCriteriaSet'