diff --git a/backend/migrations/20251022000001-add-tags-to-views.js b/backend/migrations/20251022000001-add-tags-to-views.js new file mode 100644 index 0000000..a44eeb6 --- /dev/null +++ b/backend/migrations/20251022000001-add-tags-to-views.js @@ -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'); + }, +}; diff --git a/backend/models/view.js b/backend/models/view.js index 03f04b7..f6bd2f9 100644 --- a/backend/models/view.js +++ b/backend/models/view.js @@ -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, diff --git a/backend/routes/views.js b/backend/routes/views.js index 6be6426..862b62c 100644 --- a/backend/routes/views.js +++ b/backend/routes/views.js @@ -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); diff --git a/backend/tests/integration/views.test.js b/backend/tests/integration/views.test.js new file mode 100644 index 0000000..be666b1 --- /dev/null +++ b/backend/tests/integration/views.test.js @@ -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); + }); + }); +}); diff --git a/frontend/components/UniversalSearch/SearchMenu.tsx b/frontend/components/UniversalSearch/SearchMenu.tsx index d50f2c9..8649095 100644 --- a/frontend/components/UniversalSearch/SearchMenu.tsx +++ b/frontend/components/UniversalSearch/SearchMenu.tsx @@ -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 = ({ onClose, }) => { const { t } = useTranslation(); + const { showSuccessToast, showErrorToast } = useToast(); const [selectedPriority, setSelectedPriority] = useState( null ); @@ -137,14 +140,32 @@ const SearchMenu: React.FC = ({ 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( +
+ View saved successfully! + + View now + +
+ ); + // 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); diff --git a/frontend/components/ViewDetail.tsx b/frontend/components/ViewDetail.tsx index 1a4ba50..d6d72a2 100644 --- a/frontend/components/ViewDetail.tsx +++ b/frontend/components/ViewDetail.tsx @@ -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 = () => { )} + {view.tags && + view.tags.length > 0 && ( +
+

+ {t('views.tags')} +

+
+ {view.tags.map( + (tag) => ( + + {tag} + + ) + )} +
+
+ )} {!view.filters.length && !view.search_query && !view.priority && - !view.due && ( + !view.due && + (!view.tags || + view.tags.length === 0) && (

{t( 'views.noCriteriaSet'