Fix inbox items refresh (#398)
* Fix inbox items refresh * fixup! Fix inbox items refresh
This commit is contained in:
parent
27032b5594
commit
119b04acff
6 changed files with 374 additions and 7 deletions
|
|
@ -1349,7 +1349,8 @@ router.get('/task', async (req, res) => {
|
|||
|
||||
const serializedTask = await serializeTask(
|
||||
task,
|
||||
req.currentUser.timezone
|
||||
req.currentUser.timezone,
|
||||
{ skipDisplayNameTransform: true }
|
||||
);
|
||||
|
||||
res.json(serializedTask);
|
||||
|
|
|
|||
|
|
@ -146,6 +146,82 @@ describe('Inbox Routes', () => {
|
|||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should support pagination with limit and offset', async () => {
|
||||
// Create 25 inbox items
|
||||
const items = [];
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
const item = await InboxItem.create({
|
||||
content: `Item ${i}`,
|
||||
status: 'added',
|
||||
source: 'test',
|
||||
user_id: user.id,
|
||||
});
|
||||
items.push(item);
|
||||
// Small delay to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
// Test first page (default limit of 20)
|
||||
const response1 = await agent.get('/api/inbox?limit=20&offset=0');
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response1.body.items.length).toBe(20);
|
||||
expect(response1.body.pagination.total).toBe(26); // 25 + 1 from beforeEach
|
||||
expect(response1.body.pagination.hasMore).toBe(true);
|
||||
expect(response1.body.pagination.offset).toBe(0);
|
||||
expect(response1.body.pagination.limit).toBe(20);
|
||||
|
||||
// Test second page
|
||||
const response2 = await agent.get('/api/inbox?limit=20&offset=20');
|
||||
expect(response2.status).toBe(200);
|
||||
expect(response2.body.items.length).toBe(6); // Remaining items
|
||||
expect(response2.body.pagination.total).toBe(26);
|
||||
expect(response2.body.pagination.hasMore).toBe(false);
|
||||
expect(response2.body.pagination.offset).toBe(20);
|
||||
});
|
||||
|
||||
it('should support loading more than 20 items at once', async () => {
|
||||
// Create 30 inbox items
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
await InboxItem.create({
|
||||
content: `Item ${i}`,
|
||||
status: 'added',
|
||||
source: 'test',
|
||||
user_id: user.id,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
// Request 40 items (should get all 31: 30 + 1 from beforeEach)
|
||||
const response = await agent.get('/api/inbox?limit=40&offset=0');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.items.length).toBe(31);
|
||||
expect(response.body.pagination.total).toBe(31);
|
||||
expect(response.body.pagination.hasMore).toBe(false);
|
||||
expect(response.body.pagination.limit).toBe(40);
|
||||
});
|
||||
|
||||
it('should return items in newest-first order when paginating', async () => {
|
||||
// Create 25 inbox items
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
await InboxItem.create({
|
||||
content: `Item ${i}`,
|
||||
status: 'added',
|
||||
source: 'test',
|
||||
user_id: user.id,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
// Get first page
|
||||
const response = await agent.get('/api/inbox?limit=20&offset=0');
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify newest items are first
|
||||
const items = response.body.items;
|
||||
expect(items[0].content).toBe('Item 25'); // Newest
|
||||
expect(items[19].content).toBe('Item 6'); // 20th item
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/inbox/:uid', () => {
|
||||
|
|
|
|||
|
|
@ -274,4 +274,46 @@ describe('Recurring Task Display Fixes', () => {
|
|||
expect(taskNames).toContain('Daily');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task By UID Endpoint', () => {
|
||||
it('should return actual task name when fetching recurring task by UID', async () => {
|
||||
const recurringTask = await Task.create({
|
||||
name: 'My Weekly Review',
|
||||
user_id: user.id,
|
||||
recurrence_type: 'weekly',
|
||||
recurring_parent_id: null,
|
||||
status: Task.STATUS.NOT_STARTED,
|
||||
priority: Task.PRIORITY.MEDIUM,
|
||||
});
|
||||
|
||||
const response = await agent.get(
|
||||
`/api/task?uid=${recurringTask.uid}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe('My Weekly Review');
|
||||
expect(response.body.original_name).toBe('My Weekly Review');
|
||||
expect(response.body.recurrence_type).toBe('weekly');
|
||||
});
|
||||
|
||||
it('should return actual task name for monthly recurring task by UID', async () => {
|
||||
const monthlyTask = await Task.create({
|
||||
name: 'Monthly Budget Review',
|
||||
user_id: user.id,
|
||||
recurrence_type: 'monthly',
|
||||
recurring_parent_id: null,
|
||||
status: Task.STATUS.NOT_STARTED,
|
||||
priority: Task.PRIORITY.MEDIUM,
|
||||
});
|
||||
|
||||
const response = await agent.get(
|
||||
`/api/task?uid=${monthlyTask.uid}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe('Monthly Budget Review');
|
||||
expect(response.body.original_name).toBe('Monthly Budget Review');
|
||||
expect(response.body.recurrence_type).toBe('monthly');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
212
e2e/tests/recurring-task.spec.ts
Normal file
212
e2e/tests/recurring-task.spec.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Shared login function
|
||||
async function loginAndNavigateToTasks(page, baseURL) {
|
||||
const appUrl = baseURL ?? process.env.APP_URL ?? 'http://localhost:8080';
|
||||
|
||||
// Go directly to login page first
|
||||
await page.goto(appUrl + '/login');
|
||||
|
||||
// Fill credentials and login
|
||||
const email = process.env.E2E_EMAIL || 'test@tududi.com';
|
||||
const password = process.env.E2E_PASSWORD || 'password123';
|
||||
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('button', { name: /login/i }).click();
|
||||
|
||||
// Wait for redirect to Today view
|
||||
await expect(page).toHaveURL(/\/today$/);
|
||||
|
||||
// Navigate to tasks page
|
||||
await page.goto(appUrl + '/tasks');
|
||||
await expect(page).toHaveURL(/\/tasks/);
|
||||
|
||||
// Wait for the tasks page to fully load by waiting for the task input to be visible
|
||||
await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
return appUrl;
|
||||
}
|
||||
|
||||
// Helper function to create a recurring task via API
|
||||
async function createRecurringTaskViaAPI(page, taskName: string, recurrenceType: string) {
|
||||
// Use the browser context to make an API call
|
||||
const response = await page.request.post('/api/task', {
|
||||
data: {
|
||||
name: taskName,
|
||||
recurrence_type: recurrenceType,
|
||||
status: 'not_started',
|
||||
priority: 'medium'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const task = await response.json();
|
||||
return task;
|
||||
}
|
||||
|
||||
test('recurring task displays actual name (not "Weekly") after page refresh', async ({ page, baseURL }) => {
|
||||
const appUrl = await loginAndNavigateToTasks(page, baseURL);
|
||||
|
||||
// Create a unique recurring task
|
||||
const timestamp = Date.now();
|
||||
const taskName = `My Weekly Review ${timestamp}`;
|
||||
|
||||
// Create a weekly recurring task via API
|
||||
const task = await createRecurringTaskViaAPI(page, taskName, 'weekly');
|
||||
|
||||
// Refresh the page to simulate the bug scenario
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for the tasks page to fully load
|
||||
await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on the recurring task in the task list
|
||||
const taskInList = page.locator('[data-testid*="task-item"]').filter({ hasText: taskName });
|
||||
await expect(taskInList).toBeVisible({ timeout: 10000 });
|
||||
await taskInList.click();
|
||||
|
||||
// Wait for the task modal/details page to open
|
||||
// The task modal should show the task name input or a heading with the task name
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if we're on the task detail page (URL-based) or modal (overlay)
|
||||
const currentUrl = page.url();
|
||||
|
||||
if (currentUrl.includes(`/task/${task.uid}`)) {
|
||||
// We're on the task detail page
|
||||
// The title should be displayed correctly in the page heading or task name field
|
||||
const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]');
|
||||
|
||||
// The task name should be the actual name, not "Weekly"
|
||||
const displayedText = await taskNameElement.first().textContent();
|
||||
expect(displayedText).toContain(taskName);
|
||||
expect(displayedText).not.toBe('Weekly');
|
||||
} else {
|
||||
// We're in a modal
|
||||
await expect(page.locator('[data-testid="task-name-input"]')).toBeVisible({ timeout: 5000 });
|
||||
const taskNameInput = page.locator('[data-testid="task-name-input"]');
|
||||
|
||||
// The task name input should show the actual name, not "Weekly"
|
||||
await expect(taskNameInput).toHaveValue(taskName);
|
||||
await expect(taskNameInput).not.toHaveValue('Weekly');
|
||||
}
|
||||
});
|
||||
|
||||
test('monthly recurring task displays actual name (not "Monthly") after page refresh', async ({ page, baseURL }) => {
|
||||
const appUrl = await loginAndNavigateToTasks(page, baseURL);
|
||||
|
||||
// Create a unique recurring task
|
||||
const timestamp = Date.now();
|
||||
const taskName = `Monthly Budget Review ${timestamp}`;
|
||||
|
||||
// Create a monthly recurring task via API
|
||||
const task = await createRecurringTaskViaAPI(page, taskName, 'monthly');
|
||||
|
||||
// Refresh the page to simulate the bug scenario
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for the tasks page to fully load
|
||||
await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on the recurring task in the task list
|
||||
const taskInList = page.locator('[data-testid*="task-item"]').filter({ hasText: taskName });
|
||||
await expect(taskInList).toBeVisible({ timeout: 10000 });
|
||||
await taskInList.click();
|
||||
|
||||
// Wait for the task modal/details page to open
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if we're on the task detail page (URL-based) or modal (overlay)
|
||||
const currentUrl = page.url();
|
||||
|
||||
if (currentUrl.includes(`/task/${task.uid}`)) {
|
||||
// We're on the task detail page
|
||||
const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]');
|
||||
|
||||
// The task name should be the actual name, not "Monthly"
|
||||
const displayedText = await taskNameElement.first().textContent();
|
||||
expect(displayedText).toContain(taskName);
|
||||
expect(displayedText).not.toBe('Monthly');
|
||||
} else {
|
||||
// We're in a modal
|
||||
await expect(page.locator('[data-testid="task-name-input"]')).toBeVisible({ timeout: 5000 });
|
||||
const taskNameInput = page.locator('[data-testid="task-name-input"]');
|
||||
|
||||
// The task name input should show the actual name, not "Monthly"
|
||||
await expect(taskNameInput).toHaveValue(taskName);
|
||||
await expect(taskNameInput).not.toHaveValue('Monthly');
|
||||
}
|
||||
});
|
||||
|
||||
test('daily recurring task displays actual name (not "Daily") after page refresh', async ({ page, baseURL }) => {
|
||||
const appUrl = await loginAndNavigateToTasks(page, baseURL);
|
||||
|
||||
// Create a unique recurring task
|
||||
const timestamp = Date.now();
|
||||
const taskName = `Daily Standup ${timestamp}`;
|
||||
|
||||
// Create a daily recurring task via API
|
||||
const task = await createRecurringTaskViaAPI(page, taskName, 'daily');
|
||||
|
||||
// Refresh the page to simulate the bug scenario
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for the tasks page to fully load
|
||||
await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on the recurring task in the task list
|
||||
const taskInList = page.locator('[data-testid*="task-item"]').filter({ hasText: taskName });
|
||||
await expect(taskInList).toBeVisible({ timeout: 10000 });
|
||||
await taskInList.click();
|
||||
|
||||
// Wait for the task modal/details page to open
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if we're on the task detail page (URL-based) or modal (overlay)
|
||||
const currentUrl = page.url();
|
||||
|
||||
if (currentUrl.includes(`/task/${task.uid}`)) {
|
||||
// We're on the task detail page
|
||||
const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]');
|
||||
|
||||
// The task name should be the actual name, not "Daily"
|
||||
const displayedText = await taskNameElement.first().textContent();
|
||||
expect(displayedText).toContain(taskName);
|
||||
expect(displayedText).not.toBe('Daily');
|
||||
} else {
|
||||
// We're in a modal
|
||||
await expect(page.locator('[data-testid="task-name-input"]')).toBeVisible({ timeout: 5000 });
|
||||
const taskNameInput = page.locator('[data-testid="task-name-input"]');
|
||||
|
||||
// The task name input should show the actual name, not "Daily"
|
||||
await expect(taskNameInput).toHaveValue(taskName);
|
||||
await expect(taskNameInput).not.toHaveValue('Daily');
|
||||
}
|
||||
});
|
||||
|
||||
test('recurring task shows correct name without visiting Today page first', async ({ page, baseURL }) => {
|
||||
const appUrl = await loginAndNavigateToTasks(page, baseURL);
|
||||
|
||||
// Create a unique recurring task
|
||||
const timestamp = Date.now();
|
||||
const taskName = `Weekly Planning ${timestamp}`;
|
||||
|
||||
// Create a weekly recurring task via API
|
||||
const task = await createRecurringTaskViaAPI(page, taskName, 'weekly');
|
||||
|
||||
// Navigate directly to the task detail page using the task UID
|
||||
await page.goto(`${appUrl}/task/${task.uid}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// The task detail page should show the actual name, not "Weekly"
|
||||
const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]');
|
||||
await expect(taskNameElement.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const displayedText = await taskNameElement.first().textContent();
|
||||
expect(displayedText).toContain(taskName);
|
||||
expect(displayedText).not.toBe('Weekly');
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { Note } from '../../entities/Note';
|
||||
|
|
@ -29,6 +30,10 @@ import { useStore } from '../../store/useStore';
|
|||
const InboxItems: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Track if we've done the initial load from URL
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
// Access store data
|
||||
const { inboxItems, isLoading, pagination } = useStore(
|
||||
|
|
@ -80,8 +85,13 @@ const InboxItems: React.FC = () => {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial data loading
|
||||
loadInboxItemsToStore(true);
|
||||
// Read the page size from URL parameter or use default
|
||||
const urlPageSize = searchParams.get('loaded');
|
||||
const currentLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 20;
|
||||
|
||||
// Initial data loading - load the amount specified in URL
|
||||
loadInboxItemsToStore(true, currentLoadedCount);
|
||||
setHasInitialized(true);
|
||||
|
||||
// Load projects initially
|
||||
const loadInitialProjects = async () => {
|
||||
|
|
@ -122,7 +132,9 @@ const InboxItems: React.FC = () => {
|
|||
const handleForceReload = () => {
|
||||
// Wait a short time to ensure the backend has processed the new item
|
||||
setTimeout(() => {
|
||||
loadInboxItemsToStore(false); // Don't show loading state during forced reload
|
||||
const currentInboxStore = useStore.getState().inboxStore;
|
||||
const currentCount = currentInboxStore.inboxItems.length;
|
||||
loadInboxItemsToStore(false, currentCount); // Preserve current loaded count
|
||||
}, 500);
|
||||
};
|
||||
|
||||
|
|
@ -162,7 +174,9 @@ const InboxItems: React.FC = () => {
|
|||
// This ensures real-time updates when items are added externally
|
||||
// Use a reasonable interval that balances responsiveness with performance
|
||||
const pollInterval = setInterval(() => {
|
||||
loadInboxItemsToStore(false); // Don't show loading state during polling
|
||||
const currentInboxStore = useStore.getState().inboxStore;
|
||||
const currentCount = currentInboxStore.inboxItems.length;
|
||||
loadInboxItemsToStore(false, currentCount); // Preserve current loaded count
|
||||
}, 15000); // Check for new items every 15 seconds
|
||||
|
||||
// Add event listeners
|
||||
|
|
@ -182,6 +196,26 @@ const InboxItems: React.FC = () => {
|
|||
};
|
||||
}, [t, showSuccessToast]); // Include dependencies that are actually used
|
||||
|
||||
// Update URL when inboxItems count changes (but only after initialization)
|
||||
useEffect(() => {
|
||||
// Don't update URL until we've done the initial load from URL
|
||||
if (!hasInitialized) return;
|
||||
|
||||
const urlPageSize = searchParams.get('loaded');
|
||||
const urlLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 0;
|
||||
|
||||
// Only update URL if the count has actually changed from what's in the URL
|
||||
if (inboxItems.length > 20 && inboxItems.length !== urlLoadedCount) {
|
||||
setSearchParams(
|
||||
{ loaded: inboxItems.length.toString() },
|
||||
{ replace: true }
|
||||
);
|
||||
} else if (inboxItems.length <= 20 && urlLoadedCount > 0) {
|
||||
// Remove the parameter if we're at the default page size
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
}, [inboxItems.length, hasInitialized]); // Track hasInitialized to prevent premature URL updates
|
||||
|
||||
const handleProcessItem = async (
|
||||
uid: string,
|
||||
showToast: boolean = true
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ const lastCheckTimestamp = Date.now();
|
|||
|
||||
// Store-aware functions
|
||||
export const loadInboxItemsToStore = async (
|
||||
isInitialLoad: boolean = false
|
||||
isInitialLoad: boolean = false,
|
||||
requestedCount: number = 20
|
||||
): Promise<void> => {
|
||||
const inboxStore = useStore.getState().inboxStore;
|
||||
// Only show loading for initial load, not for polling
|
||||
|
|
@ -128,7 +129,8 @@ export const loadInboxItemsToStore = async (
|
|||
}
|
||||
|
||||
try {
|
||||
const { items, pagination } = await fetchInboxItems(20, 0); // Always load first page for refresh
|
||||
// Load the requested number of items (for pagination preservation)
|
||||
const { items, pagination } = await fetchInboxItems(requestedCount, 0);
|
||||
|
||||
// Check for new items since last check (only for non-initial loads)
|
||||
if (!isInitialLoad) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue