mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🗨️ fix: Prompts Pagination (#9385)
* 🗨️ fix: Prompts Pagination
* ci: Simplify user middleware setup in prompt tests
This commit is contained in:
parent
3a47deac07
commit
460eac36f6
13 changed files with 536 additions and 237 deletions
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue