🗨️ fix: Prompts Pagination (#9385)

* 🗨️ fix: Prompts Pagination

* ci: Simplify user middleware setup in prompt tests
This commit is contained in:
Danny Avila 2025-08-30 15:58:49 -04:00 committed by GitHub
parent 3a47deac07
commit 460eac36f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 536 additions and 237 deletions

View file

@ -269,7 +269,7 @@ async function getListPromptGroupsByAccess({
const baseQuery = { ...otherParams, _id: { $in: accessibleIds } }; const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
// Add cursor condition // Add cursor condition
if (after) { if (after && typeof after === 'string' && after !== 'undefined' && after !== 'null') {
try { try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8')); const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
const { updatedAt, _id } = cursor; const { updatedAt, _id } = cursor;

View file

@ -156,7 +156,7 @@ router.get('/all', async (req, res) => {
router.get('/groups', async (req, res) => { router.get('/groups', async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
const { pageSize, pageNumber, limit, cursor, name, category, ...otherFilters } = req.query; const { pageSize, limit, cursor, name, category, ...otherFilters } = req.query;
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({ const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
name, name,
@ -171,6 +171,13 @@ router.get('/groups', async (req, res) => {
actualLimit = parseInt(pageSize, 10); actualLimit = parseInt(pageSize, 10);
} }
if (
actualCursor &&
(actualCursor === 'undefined' || actualCursor === 'null' || actualCursor.length === 0)
) {
actualCursor = null;
}
let accessibleIds = await findAccessibleResources({ let accessibleIds = await findAccessibleResources({
userId, userId,
role: req.user.role, role: req.user.role,
@ -190,6 +197,7 @@ router.get('/groups', async (req, res) => {
publicPromptGroupIds: publiclyAccessibleIds, publicPromptGroupIds: publiclyAccessibleIds,
}); });
// Cursor-based pagination only
const result = await getListPromptGroupsByAccess({ const result = await getListPromptGroupsByAccess({
accessibleIds: filteredAccessibleIds, accessibleIds: filteredAccessibleIds,
otherParams: filter, otherParams: filter,
@ -198,19 +206,21 @@ router.get('/groups', async (req, res) => {
}); });
if (!result) { if (!result) {
const emptyResponse = createEmptyPromptGroupsResponse({ pageNumber, pageSize, actualLimit }); const emptyResponse = createEmptyPromptGroupsResponse({
pageNumber: '1',
pageSize: actualLimit,
actualLimit,
});
return res.status(200).send(emptyResponse); return res.status(200).send(emptyResponse);
} }
const { data: promptGroups = [], has_more = false, after = null } = result; const { data: promptGroups = [], has_more = false, after = null } = result;
const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds); const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
const response = formatPromptGroupsResponse({ const response = formatPromptGroupsResponse({
promptGroups: groupsWithPublicFlag, promptGroups: groupsWithPublicFlag,
pageNumber, pageNumber: '1', // Always 1 for cursor-based pagination
pageSize, pageSize: actualLimit.toString(),
actualLimit,
hasMore: has_more, hasMore: has_more,
after, after,
}); });

View file

@ -33,22 +33,11 @@ let promptRoutes;
let Prompt, PromptGroup, AclEntry, AccessRole, User; let Prompt, PromptGroup, AclEntry, AccessRole, User;
let testUsers, testRoles; let testUsers, testRoles;
let grantPermission; let grantPermission;
let currentTestUser; // Track current user for middleware
// Helper function to set user in middleware // Helper function to set user in middleware
function setTestUser(app, user) { function setTestUser(app, user) {
app.use((req, res, next) => { currentTestUser = user;
req.user = {
...(user.toObject ? user.toObject() : user),
id: user.id || user._id.toString(),
_id: user._id,
name: user.name,
role: user.role,
};
if (user.role === SystemRoles.ADMIN) {
console.log('Setting admin user with role:', req.user.role);
}
next();
});
} }
beforeAll(async () => { beforeAll(async () => {
@ -75,14 +64,35 @@ beforeAll(async () => {
app = express(); app = express();
app.use(express.json()); app.use(express.json());
// Mock authentication middleware - default to owner // Add user middleware before routes
setTestUser(app, testUsers.owner); app.use((req, res, next) => {
if (currentTestUser) {
req.user = {
...(currentTestUser.toObject ? currentTestUser.toObject() : currentTestUser),
id: currentTestUser._id.toString(),
_id: currentTestUser._id,
name: currentTestUser.name,
role: currentTestUser.role,
};
}
next();
});
// Import routes after mocks are set up // Set default user
currentTestUser = testUsers.owner;
// Import routes after middleware is set up
promptRoutes = require('./prompts'); promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes); app.use('/api/prompts', promptRoutes);
}); });
afterEach(() => {
// Always reset to owner user after each test for isolation
if (currentTestUser !== testUsers.owner) {
currentTestUser = testUsers.owner;
}
});
afterAll(async () => { afterAll(async () => {
await mongoose.disconnect(); await mongoose.disconnect();
await mongoServer.stop(); await mongoServer.stop();
@ -116,36 +126,26 @@ async function setupTestData() {
// Create test users // Create test users
testUsers = { testUsers = {
owner: await User.create({ owner: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'Prompt Owner', name: 'Prompt Owner',
email: 'owner@example.com', email: 'owner@example.com',
role: SystemRoles.USER, role: SystemRoles.USER,
}), }),
viewer: await User.create({ viewer: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'Prompt Viewer', name: 'Prompt Viewer',
email: 'viewer@example.com', email: 'viewer@example.com',
role: SystemRoles.USER, role: SystemRoles.USER,
}), }),
editor: await User.create({ editor: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'Prompt Editor', name: 'Prompt Editor',
email: 'editor@example.com', email: 'editor@example.com',
role: SystemRoles.USER, role: SystemRoles.USER,
}), }),
noAccess: await User.create({ noAccess: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'No Access', name: 'No Access',
email: 'noaccess@example.com', email: 'noaccess@example.com',
role: SystemRoles.USER, role: SystemRoles.USER,
}), }),
admin: await User.create({ admin: await User.create({
id: new ObjectId().toString(),
_id: new ObjectId(),
name: 'Admin', name: 'Admin',
email: 'admin@example.com', email: 'admin@example.com',
role: SystemRoles.ADMIN, role: SystemRoles.ADMIN,
@ -181,8 +181,7 @@ describe('Prompt Routes - ACL Permissions', () => {
it('should have routes loaded', async () => { it('should have routes loaded', async () => {
// This should at least not crash // This should at least not crash
const response = await request(app).get('/api/prompts/test-404'); const response = await request(app).get('/api/prompts/test-404');
console.log('Test 404 response status:', response.status);
console.log('Test 404 response body:', response.body);
// We expect a 401 or 404, not 500 // We expect a 401 or 404, not 500
expect(response.status).not.toBe(500); expect(response.status).not.toBe(500);
}); });
@ -207,12 +206,6 @@ describe('Prompt Routes - ACL Permissions', () => {
const response = await request(app).post('/api/prompts').send(promptData); const response = await request(app).post('/api/prompts').send(promptData);
if (response.status !== 200) {
console.log('POST /api/prompts error status:', response.status);
console.log('POST /api/prompts error body:', response.body);
console.log('Console errors:', consoleErrorSpy.mock.calls);
}
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.prompt).toBeDefined(); expect(response.body.prompt).toBeDefined();
expect(response.body.prompt.prompt).toBe(promptData.prompt.prompt); expect(response.body.prompt.prompt).toBe(promptData.prompt.prompt);
@ -318,29 +311,8 @@ describe('Prompt Routes - ACL Permissions', () => {
}); });
it('should allow admin access without explicit permissions', async () => { it('should allow admin access without explicit permissions', async () => {
// First, reset the app to remove previous middleware // Set admin user
app = express(); setTestUser(app, testUsers.admin);
app.use(express.json());
// Set admin user BEFORE adding routes
app.use((req, res, next) => {
req.user = {
...testUsers.admin.toObject(),
id: testUsers.admin._id.toString(),
_id: testUsers.admin._id,
name: testUsers.admin.name,
role: testUsers.admin.role,
};
next();
});
// Now add the routes
const promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
console.log('Admin user:', testUsers.admin);
console.log('Admin role:', testUsers.admin.role);
console.log('SystemRoles.ADMIN:', SystemRoles.ADMIN);
const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200); const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200);
@ -432,21 +404,8 @@ describe('Prompt Routes - ACL Permissions', () => {
grantedBy: testUsers.editor._id, grantedBy: testUsers.editor._id,
}); });
// Recreate app with viewer user // Set viewer user
app = express(); setTestUser(app, testUsers.viewer);
app.use(express.json());
app.use((req, res, next) => {
req.user = {
...testUsers.viewer.toObject(),
id: testUsers.viewer._id.toString(),
_id: testUsers.viewer._id,
name: testUsers.viewer.name,
role: testUsers.viewer.role,
};
next();
});
const promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
await request(app) await request(app)
.delete(`/api/prompts/${authorPrompt._id}`) .delete(`/api/prompts/${authorPrompt._id}`)
@ -499,21 +458,8 @@ describe('Prompt Routes - ACL Permissions', () => {
grantedBy: testUsers.owner._id, grantedBy: testUsers.owner._id,
}); });
// Recreate app to ensure fresh middleware // Ensure owner user
app = express(); setTestUser(app, testUsers.owner);
app.use(express.json());
app.use((req, res, next) => {
req.user = {
...testUsers.owner.toObject(),
id: testUsers.owner._id.toString(),
_id: testUsers.owner._id,
name: testUsers.owner.name,
role: testUsers.owner.role,
};
next();
});
const promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
const response = await request(app) const response = await request(app)
.patch(`/api/prompts/${testPrompt._id}/tags/production`) .patch(`/api/prompts/${testPrompt._id}/tags/production`)
@ -537,21 +483,8 @@ describe('Prompt Routes - ACL Permissions', () => {
grantedBy: testUsers.owner._id, grantedBy: testUsers.owner._id,
}); });
// Recreate app with viewer user // Set viewer user
app = express(); setTestUser(app, testUsers.viewer);
app.use(express.json());
app.use((req, res, next) => {
req.user = {
...testUsers.viewer.toObject(),
id: testUsers.viewer._id.toString(),
_id: testUsers.viewer._id,
name: testUsers.viewer.name,
role: testUsers.viewer.role,
};
next();
});
const promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);
await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403); await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403);
@ -610,4 +543,305 @@ describe('Prompt Routes - ACL Permissions', () => {
expect(response.body._id).toBe(publicPrompt._id.toString()); expect(response.body._id).toBe(publicPrompt._id.toString());
}); });
}); });
describe('Pagination', () => {
beforeEach(async () => {
// Create multiple prompt groups for pagination testing
const groups = [];
for (let i = 0; i < 15; i++) {
const group = await PromptGroup.create({
name: `Test Group ${i + 1}`,
category: 'pagination-test',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - i * 1000), // Stagger updatedAt for consistent ordering
});
groups.push(group);
// Grant owner permissions on each group
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
});
afterEach(async () => {
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});
it('should correctly indicate hasMore when there are more pages', async () => {
const response = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10' })
.expect(200);
expect(response.body.promptGroups).toHaveLength(10);
expect(response.body.has_more).toBe(true);
expect(response.body.after).toBeTruthy();
// Since has_more is true, pages should be a high number (9999 in our fix)
expect(parseInt(response.body.pages)).toBeGreaterThan(1);
});
it('should correctly indicate no more pages on the last page', async () => {
// First get the cursor for page 2
const firstPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10' })
.expect(200);
expect(firstPage.body.has_more).toBe(true);
expect(firstPage.body.after).toBeTruthy();
// Now fetch the second page using the cursor
const response = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10', cursor: firstPage.body.after })
.expect(200);
expect(response.body.promptGroups).toHaveLength(5); // 15 total, 10 on page 1, 5 on page 2
expect(response.body.has_more).toBe(false);
});
it('should support cursor-based pagination', async () => {
// First page
const firstPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5' })
.expect(200);
expect(firstPage.body.promptGroups).toHaveLength(5);
expect(firstPage.body.has_more).toBe(true);
expect(firstPage.body.after).toBeTruthy();
// Second page using cursor
const secondPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', cursor: firstPage.body.after })
.expect(200);
expect(secondPage.body.promptGroups).toHaveLength(5);
expect(secondPage.body.has_more).toBe(true);
expect(secondPage.body.after).toBeTruthy();
// Verify different groups
const firstPageIds = firstPage.body.promptGroups.map((g) => g._id);
const secondPageIds = secondPage.body.promptGroups.map((g) => g._id);
expect(firstPageIds).not.toEqual(secondPageIds);
});
it('should paginate correctly with category filtering', async () => {
// Create groups with different categories
await PromptGroup.deleteMany({}); // Clear existing groups
await AclEntry.deleteMany({});
// Create 8 groups with category 'test-cat-1'
for (let i = 0; i < 8; i++) {
const group = await PromptGroup.create({
name: `Category 1 Group ${i + 1}`,
category: 'test-cat-1',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - i * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Create 7 groups with category 'test-cat-2'
for (let i = 0; i < 7; i++) {
const group = await PromptGroup.create({
name: `Category 2 Group ${i + 1}`,
category: 'test-cat-2',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - (i + 8) * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Test pagination with category filter
const firstPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', category: 'test-cat-1' })
.expect(200);
expect(firstPage.body.promptGroups).toHaveLength(5);
expect(firstPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true);
expect(firstPage.body.has_more).toBe(true);
expect(firstPage.body.after).toBeTruthy();
const secondPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', cursor: firstPage.body.after, category: 'test-cat-1' })
.expect(200);
expect(secondPage.body.promptGroups).toHaveLength(3); // 8 total, 5 on page 1, 3 on page 2
expect(secondPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true);
expect(secondPage.body.has_more).toBe(false);
});
it('should paginate correctly with name/keyword filtering', async () => {
// Create groups with specific names
await PromptGroup.deleteMany({}); // Clear existing groups
await AclEntry.deleteMany({});
// Create 12 groups with 'Search' in the name
for (let i = 0; i < 12; i++) {
const group = await PromptGroup.create({
name: `Search Test Group ${i + 1}`,
category: 'search-test',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - i * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Create 5 groups without 'Search' in the name
for (let i = 0; i < 5; i++) {
const group = await PromptGroup.create({
name: `Other Group ${i + 1}`,
category: 'other-test',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - (i + 12) * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Test pagination with name filter
const firstPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10', name: 'Search' })
.expect(200);
expect(firstPage.body.promptGroups).toHaveLength(10);
expect(firstPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true);
expect(firstPage.body.has_more).toBe(true);
expect(firstPage.body.after).toBeTruthy();
const secondPage = await request(app)
.get('/api/prompts/groups')
.query({ limit: '10', cursor: firstPage.body.after, name: 'Search' })
.expect(200);
expect(secondPage.body.promptGroups).toHaveLength(2); // 12 total, 10 on page 1, 2 on page 2
expect(secondPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true);
expect(secondPage.body.has_more).toBe(false);
});
it('should paginate correctly with combined filters', async () => {
// Create groups with various combinations
await PromptGroup.deleteMany({}); // Clear existing groups
await AclEntry.deleteMany({});
// Create 6 groups matching both category and name filters
for (let i = 0; i < 6; i++) {
const group = await PromptGroup.create({
name: `API Test Group ${i + 1}`,
category: 'api-category',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - i * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Create groups that only match one filter
for (let i = 0; i < 4; i++) {
const group = await PromptGroup.create({
name: `API Other Group ${i + 1}`,
category: 'other-category',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
updatedAt: new Date(Date.now() - (i + 6) * 1000),
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
}
// Test pagination with both filters
const response = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', name: 'API', category: 'api-category' })
.expect(200);
expect(response.body.promptGroups).toHaveLength(5);
expect(
response.body.promptGroups.every(
(g) => g.name.includes('API') && g.category === 'api-category',
),
).toBe(true);
expect(response.body.has_more).toBe(true);
expect(response.body.after).toBeTruthy();
// Page 2
const page2 = await request(app)
.get('/api/prompts/groups')
.query({ limit: '5', cursor: response.body.after, name: 'API', category: 'api-category' })
.expect(200);
expect(page2.body.promptGroups).toHaveLength(1); // 6 total, 5 on page 1, 1 on page 2
expect(page2.body.has_more).toBe(false);
});
});
}); });

View file

@ -11,9 +11,9 @@ import store from '~/store';
export default function FilterPrompts({ className = '' }: { className?: string }) { export default function FilterPrompts({ className = '' }: { className?: string }) {
const localize = useLocalize(); const localize = useLocalize();
const { setName } = usePromptGroupsContext(); const { name, setName } = usePromptGroupsContext();
const { categories } = useCategories('h-4 w-4'); const { categories } = useCategories('h-4 w-4');
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState(name || '');
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory); const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory);
@ -60,13 +60,26 @@ export default function FilterPrompts({ className = '' }: { className?: string }
[setCategory], [setCategory],
); );
// Sync displayName with name prop when it changes externally
useEffect(() => { useEffect(() => {
setDisplayName(name || '');
}, [name]);
useEffect(() => {
if (displayName === '') {
// Clear immediately when empty
setName('');
setIsSearching(false);
return;
}
setIsSearching(true); setIsSearching(true);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setIsSearching(false); setIsSearching(false);
setName(displayName); // Debounced setName call
}, 500); }, 500);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [displayName]); }, [displayName, setName]);
return ( return (
<div className={cn('flex w-full gap-2 text-text-primary', className)}> <div className={cn('flex w-full gap-2 text-text-primary', className)}>
@ -84,7 +97,6 @@ export default function FilterPrompts({ className = '' }: { className?: string }
value={displayName} value={displayName}
onChange={(e) => { onChange={(e) => {
setDisplayName(e.target.value); setDisplayName(e.target.value);
setName(e.target.value);
}} }}
isSearching={isSearching} isSearching={isSearching}
placeholder={localize('com_ui_filter_prompts_name')} placeholder={localize('com_ui_filter_prompts_name')}

View file

@ -1,10 +1,10 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useMediaQuery } from '@librechat/client'; import { useMediaQuery } from '@librechat/client';
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
import ManagePrompts from '~/components/Prompts/ManagePrompts'; import ManagePrompts from '~/components/Prompts/ManagePrompts';
import { usePromptGroupsContext } from '~/Providers'; import { usePromptGroupsContext } from '~/Providers';
import List from '~/components/Prompts/Groups/List'; import List from '~/components/Prompts/Groups/List';
import PanelNavigation from './PanelNavigation';
import { cn } from '~/utils'; import { cn } from '~/utils';
export default function GroupSidePanel({ export default function GroupSidePanel({
@ -19,38 +19,33 @@ export default function GroupSidePanel({
const location = useLocation(); const location = useLocation();
const isSmallerScreen = useMediaQuery('(max-width: 1024px)'); const isSmallerScreen = useMediaQuery('(max-width: 1024px)');
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]); const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
const {
nextPage, const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } =
prevPage, usePromptGroupsContext();
isFetching,
hasNextPage,
groupsQuery,
promptGroups,
hasPreviousPage,
} = usePromptGroupsContext();
return ( return (
<div <div
className={cn( className={cn(
'mr-2 flex h-auto w-auto min-w-72 flex-col gap-2 lg:w-1/4 xl:w-1/4', 'flex h-full w-full flex-col gap-2 md:mr-2 md:w-auto md:min-w-72 lg:w-1/4 xl:w-1/4',
isDetailView === true && isSmallerScreen ? 'hidden' : '', isDetailView === true && isSmallerScreen ? 'hidden' : '',
className, className,
)} )}
> >
{children} {children}
<div className="flex-grow overflow-y-auto"> <div className={cn('flex-grow overflow-y-auto', isChatRoute ? '' : 'px-2 md:px-0')}>
<List groups={promptGroups} isChatRoute={isChatRoute} isLoading={!!groupsQuery.isLoading} /> <List groups={promptGroups} isChatRoute={isChatRoute} isLoading={!!groupsQuery.isLoading} />
</div> </div>
<div className="flex items-center justify-between"> <div className={cn(isChatRoute ? '' : 'px-2 pb-3 pt-2 md:px-0')}>
{isChatRoute && <ManagePrompts className="select-none" />}
<PanelNavigation <PanelNavigation
nextPage={nextPage} onPrevious={prevPage}
prevPage={prevPage} onNext={nextPage}
isFetching={isFetching}
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
isChatRoute={isChatRoute}
hasPreviousPage={hasPreviousPage} hasPreviousPage={hasPreviousPage}
/> isLoading={groupsQuery.isFetching}
isChatRoute={isChatRoute}
>
{isChatRoute && <ManagePrompts className="select-none" />}
</PanelNavigation>
</div> </div>
</div> </div>
); );

View file

@ -3,42 +3,51 @@ import { Button, ThemeSelector } from '@librechat/client';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
function PanelNavigation({ function PanelNavigation({
prevPage, onPrevious,
nextPage, onNext,
hasPreviousPage,
hasNextPage, hasNextPage,
isFetching, hasPreviousPage,
isLoading,
isChatRoute, isChatRoute,
children,
}: { }: {
prevPage: () => void; onPrevious: () => void;
nextPage: () => void; onNext: () => void;
hasNextPage: boolean; hasNextPage: boolean;
hasPreviousPage: boolean; hasPreviousPage: boolean;
isFetching: boolean; isLoading?: boolean;
isChatRoute: boolean; isChatRoute: boolean;
children?: React.ReactNode;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
return ( return (
<> <div className="flex items-center justify-between">
<div className="flex gap-2">{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}</div> <div className="flex gap-2">
<div {!isChatRoute && <ThemeSelector returnThemeOnly={true} />}
className="flex items-center justify-between gap-2" {children}
role="navigation" </div>
aria-label="Pagination" <div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
> <Button
<Button variant="outline" size="sm" onClick={() => prevPage()} disabled={!hasPreviousPage}> variant="outline"
size="sm"
onClick={onPrevious}
disabled={!hasPreviousPage || isLoading}
aria-label={localize('com_ui_prev')}
>
{localize('com_ui_prev')} {localize('com_ui_prev')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => nextPage()} onClick={onNext}
disabled={!hasNextPage || isFetching} disabled={!hasNextPage || isLoading}
aria-label={localize('com_ui_next')}
> >
{localize('com_ui_next')} {localize('com_ui_next')}
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View file

@ -8,7 +8,7 @@ export default function PromptsAccordion() {
return ( return (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}> <PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>
<FilterPrompts setName={groupsNav.setName} className="items-center justify-center" /> <FilterPrompts className="items-center justify-center" />
<div className="flex w-full flex-row items-center justify-end"> <div className="flex w-full flex-row items-center justify-end">
<AutoSendPrompt className="text-xs dark:text-white" /> <AutoSendPrompt className="text-xs dark:text-white" />
</div> </div>

View file

@ -39,7 +39,7 @@ export default function PromptsView() {
<DashBreadcrumb /> <DashBreadcrumb />
<div className="flex w-full flex-grow flex-row divide-x overflow-hidden dark:divide-gray-600"> <div className="flex w-full flex-grow flex-row divide-x overflow-hidden dark:divide-gray-600">
<GroupSidePanel isDetailView={isDetailView}> <GroupSidePanel isDetailView={isDetailView}>
<div className="mx-2 mt-1 flex flex-row items-center justify-between"> <div className="mt-1 flex flex-row items-center justify-between px-2 md:px-2">
<FilterPrompts /> <FilterPrompts />
</div> </div>
</GroupSidePanel> </GroupSidePanel>

View file

@ -400,22 +400,27 @@ export const usePromptGroupsInfiniteQuery = (
params?: t.TPromptGroupsWithFilterRequest, params?: t.TPromptGroupsWithFilterRequest,
config?: UseInfiniteQueryOptions<t.PromptGroupListResponse, unknown>, config?: UseInfiniteQueryOptions<t.PromptGroupListResponse, unknown>,
) => { ) => {
const { name, pageSize, category, ...rest } = params || {}; const { name, pageSize, category } = params || {};
return useInfiniteQuery<t.PromptGroupListResponse, unknown>( return useInfiniteQuery<t.PromptGroupListResponse, unknown>(
[QueryKeys.promptGroups, name, category, pageSize], [QueryKeys.promptGroups, name, category, pageSize],
({ pageParam = '1' }) => ({ pageParam }) => {
dataService.getPromptGroups({ const queryParams: t.TPromptGroupsWithFilterRequest = {
...rest,
name, name,
category: category || '', category: category || '',
pageNumber: pageParam?.toString(), limit: (pageSize || 10).toString(),
pageSize: (pageSize || 10).toString(), };
}),
// Only add cursor if it's a valid string
if (pageParam && typeof pageParam === 'string') {
queryParams.cursor = pageParam;
}
return dataService.getPromptGroups(queryParams);
},
{ {
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
const currentPageNumber = Number(lastPage.pageNumber); // Use cursor-based pagination - ensure we return a valid cursor or undefined
const totalPages = Number(lastPage.pages); return lastPage.has_more && lastPage.after ? lastPage.after : undefined;
return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined;
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,

View file

@ -1,92 +1,108 @@
import { useMemo, useRef, useEffect } from 'react'; import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState } from 'recoil';
import { usePromptGroupsInfiniteQuery } from '~/data-provider'; import { usePromptGroupsInfiniteQuery } from '~/data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from 'librechat-data-provider';
import debounce from 'lodash/debounce';
import store from '~/store'; import store from '~/store';
export default function usePromptGroupsNav() { export default function usePromptGroupsNav() {
const queryClient = useQueryClient(); const [pageSize] = useRecoilState(store.promptsPageSize);
const category = useRecoilValue(store.promptsCategory); const [category] = useRecoilState(store.promptsCategory);
const [name, setName] = useRecoilState(store.promptsName); const [name, setName] = useRecoilState(store.promptsName);
const [pageSize, setPageSize] = useRecoilState(store.promptsPageSize);
const [pageNumber, setPageNumber] = useRecoilState(store.promptsPageNumber);
const maxPageNumberReached = useRef(1); // Track current page index and cursor history
const prevFiltersRef = useRef({ name, category, pageSize }); const [currentPageIndex, setCurrentPageIndex] = useState(0);
const cursorHistoryRef = useRef<Array<string | null>>([null]); // Start with null for first page
useEffect(() => { const prevFiltersRef = useRef({ name, category });
if (pageNumber > 1 && pageNumber > maxPageNumberReached.current) {
maxPageNumberReached.current = pageNumber;
}
}, [pageNumber]);
const groupsQuery = usePromptGroupsInfiniteQuery({ const groupsQuery = usePromptGroupsInfiniteQuery({
name, name,
pageSize, pageSize,
category, category,
pageNumber: pageNumber + '',
}); });
// Get the current page data
const currentPageData = useMemo(() => {
if (!groupsQuery.data?.pages || groupsQuery.data.pages.length === 0) {
return null;
}
// Ensure we don't go out of bounds
const pageIndex = Math.min(currentPageIndex, groupsQuery.data.pages.length - 1);
return groupsQuery.data.pages[pageIndex];
}, [groupsQuery.data?.pages, currentPageIndex]);
// Get prompt groups for current page
const promptGroups = useMemo(() => {
return currentPageData?.promptGroups || [];
}, [currentPageData]);
// Calculate pagination state
const hasNextPage = useMemo(() => {
if (!currentPageData) return false;
// If we're not on the last loaded page, we have a next page
if (currentPageIndex < (groupsQuery.data?.pages?.length || 0) - 1) {
return true;
}
// If we're on the last loaded page, check if there are more from backend
return currentPageData.has_more || false;
}, [currentPageData, currentPageIndex, groupsQuery.data?.pages?.length]);
const hasPreviousPage = currentPageIndex > 0;
const currentPage = currentPageIndex + 1;
const totalPages = hasNextPage ? currentPage + 1 : currentPage;
// Navigate to next page
const nextPage = useCallback(async () => {
if (!hasNextPage) return;
const nextPageIndex = currentPageIndex + 1;
// Check if we need to load more data
if (nextPageIndex >= (groupsQuery.data?.pages?.length || 0)) {
// We need to fetch the next page
const result = await groupsQuery.fetchNextPage();
if (result.isSuccess && result.data?.pages) {
// Update cursor history with the cursor for the next page
const lastPage = result.data.pages[result.data.pages.length - 2]; // Get the page before the newly fetched one
if (lastPage?.after && !cursorHistoryRef.current.includes(lastPage.after)) {
cursorHistoryRef.current.push(lastPage.after);
}
}
}
setCurrentPageIndex(nextPageIndex);
}, [currentPageIndex, hasNextPage, groupsQuery]);
// Navigate to previous page
const prevPage = useCallback(() => {
if (!hasPreviousPage) return;
setCurrentPageIndex(currentPageIndex - 1);
}, [currentPageIndex, hasPreviousPage]);
// Reset when filters change
useEffect(() => { useEffect(() => {
const filtersChanged = const filtersChanged =
prevFiltersRef.current.name !== name || prevFiltersRef.current.name !== name || prevFiltersRef.current.category !== category;
prevFiltersRef.current.category !== category ||
prevFiltersRef.current.pageSize !== pageSize;
if (!filtersChanged) { if (filtersChanged) {
return; setCurrentPageIndex(0);
cursorHistoryRef.current = [null];
prevFiltersRef.current = { name, category };
} }
maxPageNumberReached.current = 1; }, [name, category]);
setPageNumber(1);
// Only reset queries if we're not already on page 1
// This prevents double queries when filters change
if (pageNumber !== 1) {
queryClient.invalidateQueries([QueryKeys.promptGroups, name, category, pageSize]);
}
prevFiltersRef.current = { name, category, pageSize };
}, [pageSize, name, category, setPageNumber, pageNumber, queryClient]);
const promptGroups = useMemo(() => {
return groupsQuery.data?.pages[pageNumber - 1 + '']?.promptGroups || [];
}, [groupsQuery.data, pageNumber]);
const nextPage = () => {
setPageNumber((prev) => prev + 1);
groupsQuery.hasNextPage && groupsQuery.fetchNextPage();
};
const prevPage = () => {
setPageNumber((prev) => prev - 1);
groupsQuery.hasPreviousPage && groupsQuery.fetchPreviousPage();
};
const isFetching = groupsQuery.isFetchingNextPage;
const hasNextPage = !!groupsQuery.hasNextPage || maxPageNumberReached.current > pageNumber;
const hasPreviousPage = !!groupsQuery.hasPreviousPage || pageNumber > 1;
const debouncedSetName = useMemo(
() =>
debounce((nextValue: string) => {
setName(nextValue);
}, 850),
[setName],
);
return { return {
name, promptGroups,
setName: debouncedSetName, groupsQuery,
currentPage,
totalPages,
hasNextPage,
hasPreviousPage,
nextPage, nextPage,
prevPage, prevPage,
isFetching, isFetching: groupsQuery.isFetching,
pageSize, name,
setPageSize, setName,
hasNextPage,
groupsQuery,
promptGroups,
hasPreviousPage,
}; };
} }

View file

@ -21,9 +21,12 @@ export function formatPromptGroupsResponse({
hasMore?: boolean; hasMore?: boolean;
after?: string | null; after?: string | null;
}): PromptGroupsListResponse { }): PromptGroupsListResponse {
const effectivePageSize = parseInt(pageSize || '') || parseInt(String(actualLimit || '')) || 10; const currentPage = parseInt(pageNumber || '1');
const totalPages =
promptGroups.length > 0 ? Math.ceil(promptGroups.length / effectivePageSize).toString() : '0'; // Calculate total pages based on whether there are more results
// If hasMore is true, we know there's at least one more page
// We use a high number (9999) to indicate "many pages" since we don't know the exact count
const totalPages = hasMore ? '9999' : currentPage.toString();
return { return {
promptGroups, promptGroups,

View file

@ -252,8 +252,19 @@ export const getPromptGroup = (_id: string) => `${prompts()}/groups/${_id}`;
export const getPromptGroupsWithFilters = (filter: object) => { export const getPromptGroupsWithFilters = (filter: object) => {
let url = `${prompts()}/groups`; let url = `${prompts()}/groups`;
if (Object.keys(filter).length > 0) { // Filter out undefined/null values
const queryParams = new URLSearchParams(filter as Record<string, string>).toString(); const cleanedFilter = Object.entries(filter).reduce(
(acc, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
);
if (Object.keys(cleanedFilter).length > 0) {
const queryParams = new URLSearchParams(cleanedFilter).toString();
url += `?${queryParams}`; url += `?${queryParams}`;
} }
return url; return url;

View file

@ -536,8 +536,10 @@ export type TPromptsWithFilterRequest = {
export type TPromptGroupsWithFilterRequest = { export type TPromptGroupsWithFilterRequest = {
category: string; category: string;
pageNumber: string; pageNumber?: string; // Made optional for cursor-based pagination
pageSize: string | number; pageSize?: string | number;
limit?: string | number; // For cursor-based pagination
cursor?: string; // For cursor-based pagination
before?: string | null; before?: string | null;
after?: string | null; after?: string | null;
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
@ -550,6 +552,8 @@ export type PromptGroupListResponse = {
pageNumber: string; pageNumber: string;
pageSize: string | number; pageSize: string | number;
pages: string | number; pages: string | number;
has_more: boolean; // Added for cursor-based pagination
after: string | null; // Added for cursor-based pagination
}; };
export type PromptGroupListData = InfiniteData<PromptGroupListResponse>; export type PromptGroupListData = InfiniteData<PromptGroupListResponse>;