Merge pull request #1419 from fadingNA/pagination

Pagination
This commit is contained in:
Alex
2024-11-12 09:51:24 +00:00
committed by GitHub
19 changed files with 424 additions and 85 deletions

View File

@@ -34,10 +34,12 @@ import {
selectSelectedDocs,
selectSelectedDocsStatus,
selectSourceDocs,
selectPaginatedDocuments,
setConversations,
setModalStateDeleteConv,
setSelectedDocs,
setSourceDocs,
setPaginatedDocuments,
} from './preferences/preferenceSlice';
import Spinner from './assets/spinner.svg';
import SpinnerDark from './assets/spinner-dark.svg';
@@ -72,6 +74,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const conversations = useSelector(selectConversations);
const modalStateDeleteConv = useSelector(selectModalStateDeleteConv);
const conversationId = useSelector(selectConversationId);
const paginatedDocuments = useSelector(selectPaginatedDocuments);
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const { isMobile } = useMediaQuery();
@@ -143,9 +146,18 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
})
.then((updatedDocs) => {
dispatch(setSourceDocs(updatedDocs));
const updatedPaginatedDocs = paginatedDocuments?.filter(
(document) => document.id !== doc.id,
);
dispatch(
setPaginatedDocuments(updatedPaginatedDocs || paginatedDocuments),
);
dispatch(
setSelectedDocs(
updatedDocs?.find((doc) => doc.name.toLowerCase() === 'default'),
Array.isArray(updatedDocs) &&
updatedDocs?.find(
(doc: Doc) => doc.name.toLowerCase() === 'default',
),
),
);
})

View File

@@ -1,7 +1,8 @@
const endpoints = {
USER: {
DOCS: '/api/combine',
DOCS: '/api/sources',
DOCS_CHECK: '/api/docs_check',
DOCS_PAGINATED: '/api/sources/paginated',
API_KEYS: '/api/get_api_keys',
CREATE_API_KEY: '/api/create_api_key',
DELETE_API_KEY: '/api/delete_api_key',

View File

@@ -2,8 +2,9 @@ import apiClient from '../client';
import endpoints from '../endpoints';
const userService = {
getDocs: (sort = 'date', order = 'desc'): Promise<any> =>
apiClient.get(`${endpoints.USER.DOCS}?sort=${sort}&order=${order}`),
getDocs: (): Promise<any> => apiClient.get(`${endpoints.USER.DOCS}`),
getDocsWithPagination: (query: string): Promise<any> =>
apiClient.get(`${endpoints.USER.DOCS_PAGINATED}?${query}`),
checkDocs: (data: any): Promise<any> =>
apiClient.post(endpoints.USER.DOCS_CHECK, data),
getAPIKeys: (): Promise<any> => apiClient.get(endpoints.USER.API_KEYS),

View File

@@ -0,0 +1,5 @@
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.41 10.59L2.83 6L7.41 1.41L6 0L0 6L6 12L7.41 10.59Z" fill="black" fill-opacity="0.54" />
<path d="M15.41 10.59L10.83 6L15.41 1.41L14 0L8 6L14 12L15.41 10.59Z" fill="black"
fill-opacity="0.54" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -0,0 +1,5 @@
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.59 10.59L13.17 6L8.59 1.41L10 0L16 6L10 12L8.59 10.59Z" fill="black"
fill-opacity="0.54" />
<path d="M0.59 10.59L5.17 6L0.59 1.41L2 0L8 6L2 12L0.59 10.59Z" fill="black" fill-opacity="0.54" />
</svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@@ -0,0 +1,3 @@
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.41 10.59L2.83 6L7.41 1.41L6 0L0 6L6 12L7.41 10.59Z" fill="black" fill-opacity="0.54" />
</svg>

After

Width:  |  Height:  |  Size: 204 B

View File

@@ -0,0 +1,3 @@
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.59 1.41L5.17 6L0.59 10.59L2 12L8 6L2 0L0.59 1.41Z" fill="black" fill-opacity="0.54" />
</svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import SingleArrowLeft from '../assets/single-left-arrow.svg';
import SingleArrowRight from '../assets/single-right-arrow.svg';
import DoubleArrowLeft from '../assets/double-arrow-left.svg';
import DoubleArrowRight from '../assets/double-arrow-right.svg';
interface PaginationProps {
currentPage: number;
totalPages: number;
rowsPerPage: number;
onPageChange: (page: number) => void;
onRowsPerPageChange: (rows: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
rowsPerPage,
onPageChange,
onRowsPerPageChange,
}) => {
const [rowsPerPageOptions] = useState([5, 10, 15, 20]);
const handlePreviousPage = () => {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
};
const handleFirstPage = () => {
onPageChange(1);
};
const handleLastPage = () => {
onPageChange(totalPages);
};
return (
<div className="flex items-center text-xs justify-end gap-4 mt-2 p-2 border-gray-200">
<div className="flex items-center gap-2 ">
<span className="text-gray-900 dark:text-gray-50">Rows per page:</span>
<select
value={rowsPerPage}
onChange={(e) => onRowsPerPageChange(Number(e.target.value))}
className="border border-gray-300 rounded px-2 py-1 dark:bg-dark-charcoal dark:text-gray-50"
>
{rowsPerPageOptions.map((option) => (
<option
className="bg-white dark:bg-dark-charcoal dark:text-gray-50"
key={option}
value={option}
>
{option}
</option>
))}
</select>
</div>
<div className="text-gray-900 dark:text-gray-50">
Page {currentPage} of {totalPages}
</div>
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-50">
<button
onClick={handleFirstPage}
disabled={currentPage === 1}
className="px-2 py-1 border rounded disabled:opacity-50"
>
<img
src={DoubleArrowLeft}
alt="arrow"
className="dark:invert dark:sepia dark:brightness-200"
/>
</button>
<button
onClick={handlePreviousPage}
disabled={currentPage === 1}
className="px-2 py-1 border rounded disabled:opacity-50"
>
<img
src={SingleArrowLeft}
alt="arrow"
className="dark:invert dark:sepia dark:brightness-200"
/>
</button>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="px-2 py-1 border rounded disabled:opacity-50"
>
<img
src={SingleArrowRight}
alt="arrow"
className="dark:invert dark:sepia dark:brightness-200"
/>
</button>
<button
onClick={handleLastPage}
disabled={currentPage === totalPages}
className="px-2 py-1 border rounded disabled:opacity-50"
>
<img
src={DoubleArrowRight}
alt="arrow"
className="dark:invert dark:sepia dark:brightness-200"
/>
</button>
</div>
</div>
);
};
export default Pagination;

View File

@@ -17,11 +17,12 @@ export default function useDefaultDocument() {
getDocs().then((data) => {
dispatch(setSourceDocs(data));
if (!selectedDoc)
data?.forEach((doc: Doc) => {
if (doc.model && doc.name === 'default') {
dispatch(setSelectedDocs(doc));
}
});
Array.isArray(data) &&
data?.forEach((doc: Doc) => {
if (doc.model && doc.name === 'default') {
dispatch(setSelectedDocs(doc));
}
});
});
};

View File

@@ -50,11 +50,11 @@ body.dark {
@layer components {
.table-default {
@apply block w-max table-auto content-center justify-center rounded-xl border border-silver dark:border-silver/40 text-center dark:text-bright-gray;
@apply block w-full mx-auto table-auto content-start justify-center rounded-xl border border-silver dark:border-silver/40 text-center dark:text-bright-gray overflow-auto;
}
.table-default th {
@apply p-4 w-[244px] font-normal text-gray-400; /* Remove border-r */
@apply p-4 w-full font-normal text-gray-400 text-nowrap; /* Remove border-r */
}
.table-default th:last-child {

View File

@@ -14,6 +14,13 @@ export type Doc = {
syncFrequency?: string;
};
export type GetDocsResponse = {
docs: Doc[];
totalDocuments: number;
totalPages: number;
nextCursor: string;
};
export type PromptProps = {
prompts: { name: string; id: string; type: string }[];
selectedPrompt: { name: string; id: string; type: string };
@@ -22,7 +29,7 @@ export type PromptProps = {
};
export type DocumentsProps = {
documents: Doc[] | null;
paginatedDocuments: Doc[] | null;
handleDeleteDocument: (index: number, document: Doc) => void;
};

View File

@@ -1,18 +1,14 @@
import conversationService from '../api/services/conversationService';
import userService from '../api/services/userService';
import { Doc } from '../models/misc';
import { Doc, GetDocsResponse } from '../models/misc';
//Fetches all JSON objects from the source. We only use the objects with the "model" property in SelectDocsModal.tsx. Hopefully can clean up the source file later.
export async function getDocs(
sort = 'date',
order = 'desc',
): Promise<Doc[] | null> {
export async function getDocs(): Promise<Doc[] | null> {
try {
const response = await userService.getDocs(sort, order);
const response = await userService.getDocs();
const data = await response.json();
const docs: Doc[] = [];
data.forEach((doc: object) => {
docs.push(doc as Doc);
});
@@ -24,6 +20,33 @@ export async function getDocs(
}
}
export async function getDocsWithPagination(
sort = 'date',
order = 'desc',
pageNumber = 1,
rowsPerPage = 10,
): Promise<GetDocsResponse | null> {
try {
const query = `sort=${sort}&order=${order}&page=${pageNumber}&rows=${rowsPerPage}`;
const response = await userService.getDocsWithPagination(query);
const data = await response.json();
const docs: Doc[] = [];
Array.isArray(data.paginated) &&
data.paginated.forEach((doc: Doc) => {
docs.push(doc as Doc);
});
return {
docs: docs,
totalDocuments: data.total,
totalPages: data.totalPages,
nextCursor: data.nextCursor,
};
} catch (error) {
console.log(error);
return null;
}
}
export async function getConversations(): Promise<{
data: { name: string; id: string }[] | null;
loading: boolean;

View File

@@ -20,6 +20,7 @@ export interface Preference {
loading: boolean;
};
modalState: ActiveState;
paginatedDocuments: Doc[] | null;
}
const initialState: Preference = {
@@ -42,6 +43,7 @@ const initialState: Preference = {
loading: false,
},
modalState: 'INACTIVE',
paginatedDocuments: null,
};
export const prefSlice = createSlice({
@@ -57,6 +59,9 @@ export const prefSlice = createSlice({
setSourceDocs: (state, action) => {
state.sourceDocs = action.payload;
},
setPaginatedDocuments: (state, action) => {
state.paginatedDocuments = action.payload;
},
setConversations: (state, action) => {
state.conversations = action.payload;
},
@@ -84,6 +89,7 @@ export const {
setChunks,
setTokenLimit,
setModalStateDeleteConv,
setPaginatedDocuments,
} = prefSlice.actions;
export default prefSlice.reducer;
@@ -155,3 +161,5 @@ export const selectPrompt = (state: RootState) => state.preference.prompt;
export const selectChunks = (state: RootState) => state.preference.chunks;
export const selectTokenLimit = (state: RootState) =>
state.preference.token_limit;
export const selectPaginatedDocuments = (state: RootState) =>
state.preference.paginatedDocuments;

View File

@@ -1,19 +1,20 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import userService from '../api/services/userService';
import SyncIcon from '../assets/sync.svg';
import Trash from '../assets/trash.svg';
import caretSort from '../assets/caret-sort.svg';
import DropdownMenu from '../components/DropdownMenu';
import { Doc, DocumentsProps, ActiveState } from '../models/misc'; // Ensure ActiveState type is imported
import SkeletonLoader from '../components/SkeletonLoader';
import { getDocs } from '../preferences/preferenceApi';
import { setSourceDocs } from '../preferences/preferenceSlice';
import Input from '../components/Input';
import Upload from '../upload/Upload'; // Import the Upload component
import Pagination from '../components/DocumentPagination';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { Doc, DocumentsProps, ActiveState } from '../models/misc'; // Ensure ActiveState type is imported
import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi';
import { setSourceDocs } from '../preferences/preferenceSlice';
import { setPaginatedDocuments } from '../preferences/preferenceSlice';
// Utility function to format numbers
const formatTokens = (tokens: number): string => {
@@ -33,12 +34,11 @@ const formatTokens = (tokens: number): string => {
};
const Documents: React.FC<DocumentsProps> = ({
documents,
paginatedDocuments,
handleDeleteDocument,
}) => {
const { t } = useTranslation();
const dispatch = useDispatch();
// State for search input
const [searchTerm, setSearchTerm] = useState('');
// State for modal: active/inactive
@@ -47,37 +47,49 @@ const Documents: React.FC<DocumentsProps> = ({
const [loading, setLoading] = useState(false);
const [sortField, setSortField] = useState<'date' | 'tokens'>('date');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Pagination
const [currentPage, setCurrentPage] = useState<number>(1);
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
const [totalPages, setTotalPages] = useState<number>(1);
// const [totalDocuments, setTotalDocuments] = useState<number>(0);
// Filter documents based on the search term
const filteredDocuments = paginatedDocuments?.filter((document) =>
document.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
// State for documents
const currentDocuments = filteredDocuments ?? [];
console.log('currentDocuments', currentDocuments);
const syncOptions = [
{ label: 'Never', value: 'never' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
];
const refreshDocs = (field: 'date' | 'tokens') => {
if (field === sortField) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortOrder('desc');
setSortField(field);
const refreshDocs = (
field: 'date' | 'tokens' | undefined,
pageNumber?: number,
rows?: number,
) => {
const page = pageNumber ?? currentPage;
const rowsPerPg = rows ?? rowsPerPage;
if (field !== undefined) {
if (field === sortField) {
// Toggle sort order
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
// Change sort field and reset order to 'desc'
setSortField(field);
setSortOrder('desc');
}
}
getDocs(sortField, sortOrder)
getDocsWithPagination(sortField, sortOrder, page, rowsPerPg)
.then((data) => {
dispatch(setSourceDocs(data));
})
.catch((error) => console.error(error))
.finally(() => {
setLoading(false);
});
};
const handleManageSync = (doc: Doc, sync_frequency: string) => {
setLoading(true);
userService
.manageSync({ source_id: doc.id, sync_frequency })
.then(() => {
return getDocs();
})
.then((data) => {
dispatch(setSourceDocs(data));
//dispatch(setSourceDocs(data ? data.docs : []));
dispatch(setPaginatedDocuments(data ? data.docs : []));
setTotalPages(data ? data.totalPages : 0);
//setTotalDocuments(data ? data.totalDocuments : 0);
})
.catch((error) => console.error(error))
.finally(() => {
@@ -85,10 +97,40 @@ const Documents: React.FC<DocumentsProps> = ({
});
};
// Filter documents based on the search term
const filteredDocuments = documents?.filter((document) =>
document.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
const handleManageSync = (doc: Doc, sync_frequency: string) => {
setLoading(true);
userService
.manageSync({ source_id: doc.id, sync_frequency })
.then(() => {
// First, fetch the updated source docs
return getDocs();
})
.then((data) => {
dispatch(setSourceDocs(data));
return getDocsWithPagination(
sortField,
sortOrder,
currentPage,
rowsPerPage,
);
})
.then((paginatedData) => {
dispatch(
setPaginatedDocuments(paginatedData ? paginatedData.docs : []),
);
setTotalPages(paginatedData ? paginatedData.totalPages : 0);
})
.catch((error) => console.error('Error in handleManageSync:', error))
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
if (modalState === 'INACTIVE') {
refreshDocs(sortField, currentPage, rowsPerPage);
}
}, [modalState, sortField, currentPage, rowsPerPage]);
return (
<div className="mt-8">
@@ -154,16 +196,16 @@ const Documents: React.FC<DocumentsProps> = ({
</tr>
</thead>
<tbody>
{!filteredDocuments?.length && (
{!currentDocuments?.length && (
<tr>
<td colSpan={5} className="!p-4">
{t('settings.documents.noData')}
</td>
</tr>
)}
{filteredDocuments &&
filteredDocuments.map((document, index) => (
<tr key={index}>
{Array.isArray(currentDocuments) &&
currentDocuments.map((document, index) => (
<tr key={index} className="text-nowrap font-normal">
<td>{document.name}</td>
<td>{document.date}</td>
<td>
@@ -173,7 +215,7 @@ const Documents: React.FC<DocumentsProps> = ({
{document.type === 'remote' ? 'Pre-loaded' : 'Private'}
</td>
<td>
<div className="flex flex-row items-center">
<div className="min-w-[70px] flex flex-row items-end justify-end ml-auto">
{document.type !== 'remote' && (
<img
src={Trash}
@@ -221,12 +263,31 @@ const Documents: React.FC<DocumentsProps> = ({
</div>
)}
</div>
{/* Pagination component with props:
# Note: Every time the page changes,
the refreshDocs function is called with the updated page number and rows per page.
and reset cursor paginated query parameter to undefined.
*/}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
rowsPerPage={rowsPerPage}
onPageChange={(page) => {
setCurrentPage(page);
refreshDocs(sortField, page, rowsPerPage);
}}
onRowsPerPageChange={(rows) => {
setRowsPerPage(rows);
setCurrentPage(1);
refreshDocs(sortField, 1, rows);
}}
/>
</div>
);
};
Documents.propTypes = {
documents: PropTypes.array.isRequired,
//documents: PropTypes.array.isRequired,
handleDeleteDocument: PropTypes.func.isRequired,
};

View File

@@ -8,6 +8,8 @@ import i18n from '../locale/i18n';
import { Doc } from '../models/misc';
import {
selectSourceDocs,
selectPaginatedDocuments,
setPaginatedDocuments,
setSourceDocs,
} from '../preferences/preferenceSlice';
import Analytics from './Analytics';
@@ -26,20 +28,29 @@ export default function Settings() {
);
const documents = useSelector(selectSourceDocs);
const paginatedDocuments = useSelector(selectPaginatedDocuments);
const updateWidgetScreenshot = (screenshot: File | null) => {
setWidgetScreenshot(screenshot);
};
const updateDocumentsList = (documents: Doc[], index: number) => [
...documents.slice(0, index),
...documents.slice(index + 1),
];
const handleDeleteClick = (index: number, doc: Doc) => {
userService
.deletePath(doc.id ?? '')
.then((response) => {
if (response.ok && documents) {
const updatedDocuments = [
...documents.slice(0, index),
...documents.slice(index + 1),
];
dispatch(setSourceDocs(updatedDocuments));
if (paginatedDocuments) {
dispatch(
setPaginatedDocuments(
updateDocumentsList(paginatedDocuments, index),
),
);
}
dispatch(setSourceDocs(updateDocumentsList(documents, index)));
}
})
.catch((error) => console.error(error));
@@ -72,7 +83,7 @@ export default function Settings() {
case t('settings.documents.label'):
return (
<Documents
documents={documents}
paginatedDocuments={paginatedDocuments}
handleDeleteDocument={handleDeleteClick}
/>
);

View File

@@ -38,6 +38,7 @@ const preloadedState: { preference: Preference } = {
},
],
modalState: 'INACTIVE',
paginatedDocuments: null,
},
};
const store = configureStore({

View File

@@ -166,7 +166,10 @@ function Upload({
dispatch(setSourceDocs(data));
dispatch(
setSelectedDocs(
data?.find((d) => d.type?.toLowerCase() === 'local'),
Array.isArray(data) &&
data?.find(
(d: Doc) => d.type?.toLowerCase() === 'local',
),
),
);
});
@@ -182,15 +185,21 @@ function Upload({
getDocs().then((data) => {
dispatch(setSourceDocs(data));
const docIds = new Set(
sourceDocs?.map((doc: Doc) => (doc.id ? doc.id : null)),
(Array.isArray(sourceDocs) &&
sourceDocs?.map((doc: Doc) =>
doc.id ? doc.id : null,
)) ||
[],
);
data?.map((updatedDoc: Doc) => {
if (updatedDoc.id && !docIds.has(updatedDoc.id)) {
//select the doc not present in the intersection of current Docs and fetched data
dispatch(setSelectedDocs(updatedDoc));
return;
}
});
if (data && Array.isArray(data)) {
data.map((updatedDoc: Doc) => {
if (updatedDoc.id && !docIds.has(updatedDoc.id)) {
// Select the doc not present in the intersection of current Docs and fetched data
dispatch(setSelectedDocs(updatedDoc));
return;
}
});
}
});
setProgress(
(progress) =>