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:
parent
c6407570c7
commit
48ee54a12f
6 changed files with 567 additions and 3 deletions
22
backend/migrations/20251022000001-add-tags-to-views.js
Normal file
22
backend/migrations/20251022000001-add-tags-to-views.js
Normal 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');
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
479
backend/tests/integration/views.test.js
Normal file
479
backend/tests/integration/views.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue