Compare commits
39 Commits
main
...
feat/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f548bec17 | ||
|
|
4f4e0937f7 | ||
|
|
7958401979 | ||
|
|
ab706ecf70 | ||
|
|
f490f1a87f | ||
|
|
ff67edc75c | ||
|
|
3cb21de1df | ||
|
|
342656156a | ||
|
|
1968cf55eb | ||
|
|
334b5f8853 | ||
|
|
c0d371a24c | ||
|
|
c5a0bc6298 | ||
|
|
1b2006af12 | ||
|
|
ccb378c903 | ||
|
|
e7b209ee09 | ||
|
|
7eff895121 | ||
|
|
2872058dcf | ||
|
|
caadc4e85d | ||
|
|
9a4e657fcd | ||
|
|
d2299b86ec | ||
|
|
cd85162076 | ||
|
|
ccad6db7c5 | ||
|
|
ee91891e20 | ||
|
|
0ebe96f47e | ||
|
|
2f532ea8d3 | ||
|
|
1c612ba364 | ||
|
|
df16406401 | ||
|
|
2a9295ba0c | ||
|
|
6e47b8800f | ||
|
|
0396dd7e78 | ||
|
|
7a5996871c | ||
|
|
ee00dcdb60 | ||
|
|
507bfb5989 | ||
|
|
c2e0ed8ad6 | ||
|
|
61daedc9df | ||
|
|
87f31c1dbd | ||
|
|
ab74ce262e | ||
|
|
0cd45d24fc | ||
|
|
e32bd14c89 |
@@ -74,7 +74,7 @@ describe('addImages', () => {
|
||||
|
||||
it('should append correctly from a real scenario', () => {
|
||||
responseMessage.text =
|
||||
'Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there\'s a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?';
|
||||
"Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?";
|
||||
const originalText = responseMessage.text;
|
||||
const imageMarkdown = '';
|
||||
intermediateSteps.push({ observation: imageMarkdown });
|
||||
|
||||
@@ -65,14 +65,14 @@ function buildPromptPrefix({ result, message, functionsAgent }) {
|
||||
const preliminaryAnswer =
|
||||
result.output?.length > 0 ? `Preliminary Answer: "${result.output.trim()}"` : '';
|
||||
const prefix = preliminaryAnswer
|
||||
? 'review and improve the answer you generated using plugins in response to the User Message below. The user hasn\'t seen your answer or thoughts yet.'
|
||||
? "review and improve the answer you generated using plugins in response to the User Message below. The user hasn't seen your answer or thoughts yet."
|
||||
: 'respond to the User Message below based on your preliminary thoughts & actions.';
|
||||
|
||||
return `As a helpful AI Assistant, ${prefix}${errorMessage}\n${internalActions}
|
||||
${preliminaryAnswer}
|
||||
Reply conversationally to the User based on your ${
|
||||
preliminaryAnswer ? 'preliminary answer, ' : ''
|
||||
}internal actions, thoughts, and observations, making improvements wherever possible, but do not modify URLs.
|
||||
preliminaryAnswer ? 'preliminary answer, ' : ''
|
||||
}internal actions, thoughts, and observations, making improvements wherever possible, but do not modify URLs.
|
||||
${
|
||||
preliminaryAnswer
|
||||
? ''
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('formatAgentMessages', () => {
|
||||
content: [
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: 'I\'ll search for that information.',
|
||||
[ContentTypes.TEXT]: "I'll search for that information.",
|
||||
tool_call_ids: ['search_1'],
|
||||
},
|
||||
{
|
||||
@@ -144,7 +144,7 @@ describe('formatAgentMessages', () => {
|
||||
},
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: 'Now, I\'ll convert the temperature.',
|
||||
[ContentTypes.TEXT]: "Now, I'll convert the temperature.",
|
||||
tool_call_ids: ['convert_1'],
|
||||
},
|
||||
{
|
||||
@@ -156,7 +156,7 @@ describe('formatAgentMessages', () => {
|
||||
output: '23.89°C',
|
||||
},
|
||||
},
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s your answer.' },
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -171,7 +171,7 @@ describe('formatAgentMessages', () => {
|
||||
expect(result[4]).toBeInstanceOf(AIMessage);
|
||||
|
||||
// Check first AIMessage
|
||||
expect(result[0].content).toBe('I\'ll search for that information.');
|
||||
expect(result[0].content).toBe("I'll search for that information.");
|
||||
expect(result[0].tool_calls).toHaveLength(1);
|
||||
expect(result[0].tool_calls[0]).toEqual({
|
||||
id: 'search_1',
|
||||
@@ -187,7 +187,7 @@ describe('formatAgentMessages', () => {
|
||||
);
|
||||
|
||||
// Check second AIMessage
|
||||
expect(result[2].content).toBe('Now, I\'ll convert the temperature.');
|
||||
expect(result[2].content).toBe("Now, I'll convert the temperature.");
|
||||
expect(result[2].tool_calls).toHaveLength(1);
|
||||
expect(result[2].tool_calls[0]).toEqual({
|
||||
id: 'convert_1',
|
||||
@@ -202,7 +202,7 @@ describe('formatAgentMessages', () => {
|
||||
|
||||
// Check final AIMessage
|
||||
expect(result[4].content).toStrictEqual([
|
||||
{ [ContentTypes.TEXT]: 'Here\'s your answer.', type: ContentTypes.TEXT },
|
||||
{ [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('formatAgentMessages', () => {
|
||||
role: 'assistant',
|
||||
content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
|
||||
},
|
||||
{ role: 'user', content: 'What\'s the weather?' },
|
||||
{ role: 'user', content: "What's the weather?" },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
@@ -240,7 +240,7 @@ describe('formatAgentMessages', () => {
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s the weather information.' },
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -265,12 +265,12 @@ describe('formatAgentMessages', () => {
|
||||
{ [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
|
||||
]);
|
||||
expect(result[2].content).toStrictEqual([
|
||||
{ [ContentTypes.TEXT]: 'What\'s the weather?', type: ContentTypes.TEXT },
|
||||
{ [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT },
|
||||
]);
|
||||
expect(result[3].content).toBe('Let me check that for you.');
|
||||
expect(result[4].content).toBe('Sunny, 75°F');
|
||||
expect(result[5].content).toStrictEqual([
|
||||
{ [ContentTypes.TEXT]: 'Here\'s the weather information.', type: ContentTypes.TEXT },
|
||||
{ [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT },
|
||||
]);
|
||||
|
||||
// Check that there are no consecutive AIMessages
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module.exports = {
|
||||
instructions:
|
||||
'Remember, all your responses MUST be in the format described. Do not respond unless it\'s in the format described, using the structure of Action, Action Input, etc.',
|
||||
"Remember, all your responses MUST be in the format described. Do not respond unless it's in the format described, using the structure of Action, Action Input, etc.",
|
||||
errorInstructions:
|
||||
'\nYou encountered an error in attempting a response. The user is not aware of the error so you shouldn\'t mention it.\nReview the actions taken carefully in case there is a partial or complete answer within them.\nError Message:',
|
||||
"\nYou encountered an error in attempting a response. The user is not aware of the error so you shouldn't mention it.\nReview the actions taken carefully in case there is a partial or complete answer within them.\nError Message:",
|
||||
imageInstructions:
|
||||
'You must include the exact image paths from above, formatted in Markdown syntax: ',
|
||||
completionInstructions:
|
||||
|
||||
@@ -18,17 +18,17 @@ function generateShadcnPrompt(options) {
|
||||
Here are the components that are available, along with how to import them, and how to use them:
|
||||
|
||||
${Object.values(components)
|
||||
.map((component) => {
|
||||
if (useXML) {
|
||||
return dedent`
|
||||
.map((component) => {
|
||||
if (useXML) {
|
||||
return dedent`
|
||||
<component>
|
||||
<name>${component.componentName}</name>
|
||||
<import-instructions>${component.importDocs}</import-instructions>
|
||||
<usage-instructions>${component.usageDocs}</usage-instructions>
|
||||
</component>
|
||||
`;
|
||||
} else {
|
||||
return dedent`
|
||||
} else {
|
||||
return dedent`
|
||||
# ${component.componentName}
|
||||
|
||||
## Import Instructions
|
||||
@@ -37,9 +37,9 @@ function generateShadcnPrompt(options) {
|
||||
## Usage Instructions
|
||||
${component.usageDocs}
|
||||
`;
|
||||
}
|
||||
})
|
||||
.join('\n\n')}
|
||||
}
|
||||
})
|
||||
.join('\n\n')}
|
||||
`;
|
||||
|
||||
return systemPrompt;
|
||||
|
||||
@@ -232,7 +232,7 @@ class OpenWeather extends Tool {
|
||||
|
||||
if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) {
|
||||
if (typeof finalLat !== 'number' || typeof finalLon !== 'number') {
|
||||
return 'Error: lat and lon are required and must be numbers for this action (or specify \'city\').';
|
||||
return "Error: lat and lon are required and must be numbers for this action (or specify 'city').";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ class OpenWeather extends Tool {
|
||||
let dt;
|
||||
if (action === 'timestamp') {
|
||||
if (!date) {
|
||||
return 'Error: For timestamp action, a \'date\' in YYYY-MM-DD format is required.';
|
||||
return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required.";
|
||||
}
|
||||
dt = this.convertDateToUnix(date);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const getConvo = async (user, conversationId) => {
|
||||
return await Conversation.findOne({ user, conversationId }).lean();
|
||||
} catch (error) {
|
||||
logger.error('[getConvo] Error getting single conversation', error);
|
||||
return { message: 'Error getting single conversation' };
|
||||
throw new Error('Error getting single conversation');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,13 +151,21 @@ module.exports = {
|
||||
const result = await Conversation.bulkWrite(bulkOps);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[saveBulkConversations] Error saving conversations in bulk', error);
|
||||
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
|
||||
throw new Error('Failed to save conversations in bulk.');
|
||||
}
|
||||
},
|
||||
getConvosByCursor: async (
|
||||
user,
|
||||
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
||||
{
|
||||
cursor,
|
||||
limit = 25,
|
||||
isArchived = false,
|
||||
tags,
|
||||
search,
|
||||
sortBy = 'createdAt',
|
||||
sortDirection = 'desc',
|
||||
} = {},
|
||||
) => {
|
||||
const filters = [{ user }];
|
||||
if (isArchived) {
|
||||
@@ -184,35 +192,77 @@ module.exports = {
|
||||
filters.push({ conversationId: { $in: matchingIds } });
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error during meiliSearch', error);
|
||||
return { message: 'Error during meiliSearch' };
|
||||
throw new Error('Error during meiliSearch');
|
||||
}
|
||||
}
|
||||
|
||||
const validSortFields = ['title', 'createdAt', 'updatedAt'];
|
||||
if (!validSortFields.includes(sortBy)) {
|
||||
throw new Error(
|
||||
`Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const finalSortBy = sortBy;
|
||||
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
let cursorFilter = null;
|
||||
if (cursor) {
|
||||
filters.push({ updatedAt: { $lt: new Date(cursor) } });
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
|
||||
const { primary, secondary } = decoded;
|
||||
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
|
||||
const secondaryValue = new Date(secondary);
|
||||
const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
|
||||
|
||||
cursorFilter = {
|
||||
$or: [
|
||||
{ [finalSortBy]: { [op]: primaryValue } },
|
||||
{
|
||||
[finalSortBy]: primaryValue,
|
||||
updatedAt: { [op]: secondaryValue },
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
|
||||
}
|
||||
if (cursorFilter) {
|
||||
filters.push(cursorFilter);
|
||||
}
|
||||
}
|
||||
|
||||
const query = filters.length === 1 ? filters[0] : { $and: filters };
|
||||
|
||||
try {
|
||||
const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
|
||||
const sortObj = { [finalSortBy]: sortOrder };
|
||||
|
||||
if (finalSortBy !== 'updatedAt') {
|
||||
sortObj.updatedAt = sortOrder;
|
||||
}
|
||||
|
||||
const convos = await Conversation.find(query)
|
||||
.select(
|
||||
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
||||
)
|
||||
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
|
||||
.sort(sortObj)
|
||||
.limit(limit + 1)
|
||||
.lean();
|
||||
|
||||
let nextCursor = null;
|
||||
if (convos.length > limit) {
|
||||
const lastConvo = convos.pop();
|
||||
nextCursor = lastConvo.updatedAt.toISOString();
|
||||
const primaryValue = lastConvo[finalSortBy];
|
||||
const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString();
|
||||
const secondaryStr = lastConvo.updatedAt.toISOString();
|
||||
const composite = { primary: primaryStr, secondary: secondaryStr };
|
||||
nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
|
||||
}
|
||||
|
||||
return { conversations: convos, nextCursor };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error getting conversations', error);
|
||||
return { message: 'Error getting conversations' };
|
||||
throw new Error('Error getting conversations');
|
||||
}
|
||||
},
|
||||
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
||||
@@ -252,7 +302,7 @@ module.exports = {
|
||||
return { conversations: limited, nextCursor, convoMap };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosQueried] Error getting conversations', error);
|
||||
return { message: 'Error fetching conversations' };
|
||||
throw new Error('Error fetching conversations');
|
||||
}
|
||||
},
|
||||
getConvo,
|
||||
@@ -269,7 +319,7 @@ module.exports = {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[getConvoTitle] Error getting conversation title', error);
|
||||
return { message: 'Error getting conversation title' };
|
||||
throw new Error('Error getting conversation title');
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -43,7 +43,6 @@ afterEach(() => {
|
||||
|
||||
//TODO: This works/passes locally but http request tests fail with 404 in CI. Need to figure out why.
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('GET /', () => {
|
||||
it('should return 200 and the correct body', async () => {
|
||||
process.env.APP_TITLE = 'Test Title';
|
||||
|
||||
@@ -31,7 +31,8 @@ router.get('/', async (req, res) => {
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
|
||||
const order = req.query.order || 'desc';
|
||||
const sortBy = req.query.sortBy || 'createdAt';
|
||||
const sortDirection = req.query.sortDirection || 'desc';
|
||||
|
||||
let tags;
|
||||
if (req.query.tags) {
|
||||
@@ -45,7 +46,8 @@ router.get('/', async (req, res) => {
|
||||
isArchived,
|
||||
tags,
|
||||
search,
|
||||
order,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,6 @@ const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
const { getAssistant } = require('~/models/Assistant');
|
||||
|
||||
const buildOptions = async (endpoint, parsedBody) => {
|
||||
|
||||
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
|
||||
parsedBody;
|
||||
const endpointOption = removeNullishValues({
|
||||
|
||||
@@ -255,7 +255,7 @@ describe('processMessages', () => {
|
||||
type: 'text',
|
||||
text: {
|
||||
value:
|
||||
'The text you have uploaded is from the book "Harry Potter and the Philosopher\'s Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander\'s【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher\'s Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry\'s initial experiences in the magical world and set the stage for his adventures at Hogwarts.',
|
||||
"The text you have uploaded is from the book \"Harry Potter and the Philosopher's Stone\" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander's【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher's Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry's initial experiences in the magical world and set the stage for his adventures at Hogwarts.",
|
||||
annotations: [
|
||||
{
|
||||
type: 'file_citation',
|
||||
@@ -424,7 +424,7 @@ These points highlight Harry's initial experiences in the magical world and set
|
||||
type: 'text',
|
||||
text: {
|
||||
value:
|
||||
'The text you have uploaded is from the book "Harry Potter and the Philosopher\'s Stone" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander\'s【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher\'s Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry\'s initial experiences in the magical world and set the stage for his adventures at Hogwarts.',
|
||||
"The text you have uploaded is from the book \"Harry Potter and the Philosopher's Stone\" by J.K. Rowling. It follows the story of a young boy named Harry Potter who discovers that he is a wizard on his eleventh birthday. Here are some key points of the narrative:\n\n1. **Discovery and Invitation to Hogwarts**: Harry learns that he is a wizard and receives an invitation to attend Hogwarts School of Witchcraft and Wizardry【11:2†source】【11:4†source】.\n\n2. **Shopping for Supplies**: Hagrid takes Harry to Diagon Alley to buy his school supplies, including his wand from Ollivander's【11:9†source】【11:14†source】.\n\n3. **Introduction to Hogwarts**: Harry is introduced to Hogwarts, the magical school where he will learn about magic and discover more about his own background【11:12†source】【11:18†source】.\n\n4. **Meeting Friends and Enemies**: At Hogwarts, Harry makes friends like Ron Weasley and Hermione Granger, and enemies like Draco Malfoy【11:16†source】.\n\n5. **Uncovering the Mystery**: Harry, along with Ron and Hermione, uncovers the mystery of the Philosopher's Stone and its connection to the dark wizard Voldemort【11:1†source】【11:10†source】【11:7†source】.\n\nThese points highlight Harry's initial experiences in the magical world and set the stage for his adventures at Hogwarts.",
|
||||
annotations: [
|
||||
{
|
||||
type: 'file_citation',
|
||||
@@ -582,7 +582,7 @@ These points highlight Harry's initial experiences in the magical world and set
|
||||
type: 'text',
|
||||
text: {
|
||||
value:
|
||||
'This is a test ^1^ with pre-existing citation-like text. Here\'s a real citation【11:2†source】.',
|
||||
"This is a test ^1^ with pre-existing citation-like text. Here's a real citation【11:2†source】.",
|
||||
annotations: [
|
||||
{
|
||||
type: 'file_citation',
|
||||
@@ -610,7 +610,7 @@ These points highlight Harry's initial experiences in the magical world and set
|
||||
});
|
||||
|
||||
const expectedText =
|
||||
'This is a test ^1^ with pre-existing citation-like text. Here\'s a real citation^1^.\n\n^1.^ test.txt';
|
||||
"This is a test ^1^ with pre-existing citation-like text. Here's a real citation^1^.\n\n^1.^ test.txt";
|
||||
|
||||
expect(result.text).toBe(expectedText);
|
||||
expect(result.edited).toBe(true);
|
||||
|
||||
12
client/src/@types/i18next.d.ts
vendored
12
client/src/@types/i18next.d.ts
vendored
@@ -1,9 +1,9 @@
|
||||
import { defaultNS, resources } from '~/locales/i18n';
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
strictKeyChecks: true
|
||||
}
|
||||
}
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
strictKeyChecks: true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,6 @@ test('renders registration form', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// test('calls registerUser.mutate on registration', async () => {
|
||||
// const mutate = jest.fn();
|
||||
// const { getByTestId, getByRole, history } = setup({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useState, useCallback, useMemo } from 'react';
|
||||
import React, { memo, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { CheckboxButton } from '@librechat/client';
|
||||
import { ArtifactModes } from 'librechat-data-provider';
|
||||
@@ -18,6 +18,7 @@ function Artifacts() {
|
||||
const { toggleState, debouncedChange, isPinned } = artifacts;
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [isButtonExpanded, setIsButtonExpanded] = useState(false);
|
||||
|
||||
const currentState = useMemo<ArtifactsToggleState>(() => {
|
||||
if (typeof toggleState === 'string' && toggleState) {
|
||||
@@ -33,11 +34,26 @@ function Artifacts() {
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isEnabled) {
|
||||
debouncedChange({ value: '' });
|
||||
setIsButtonExpanded(false);
|
||||
} else {
|
||||
debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
}
|
||||
}, [isEnabled, debouncedChange]);
|
||||
|
||||
const handleMenuButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsButtonExpanded(!isButtonExpanded);
|
||||
},
|
||||
[isButtonExpanded],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopoverOpen) {
|
||||
setIsButtonExpanded(false);
|
||||
}
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
const handleShadcnToggle = useCallback(() => {
|
||||
if (isShadcnEnabled) {
|
||||
debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||
@@ -77,21 +93,24 @@ function Artifacts() {
|
||||
'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
|
||||
'transition-colors',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={handleMenuButtonClick}
|
||||
>
|
||||
<ChevronDown className="ml-1 h-4 w-4 text-text-secondary md:ml-0" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ml-1 h-4 w-4 text-text-secondary transition-transform duration-300 md:ml-0.5',
|
||||
isButtonExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</Ariakit.MenuButton>
|
||||
|
||||
<Ariakit.Menu
|
||||
gutter={8}
|
||||
gutter={4}
|
||||
className={cn(
|
||||
'animate-popover z-50 flex max-h-[300px]',
|
||||
'flex-col overflow-auto overscroll-contain rounded-xl',
|
||||
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
|
||||
'border border-border-light',
|
||||
'min-w-[250px] outline-none',
|
||||
'animate-popover-top-left z-50 flex min-w-[250px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary shadow-lg',
|
||||
)}
|
||||
portal
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="mb-2 text-xs font-medium text-text-secondary">
|
||||
@@ -106,18 +125,16 @@ function Artifacts() {
|
||||
event.stopPropagation();
|
||||
handleShadcnToggle();
|
||||
}}
|
||||
disabled={isCustomEnabled}
|
||||
className={cn(
|
||||
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
||||
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||
isShadcnEnabled && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
@@ -130,15 +147,15 @@ function Artifacts() {
|
||||
handleCustomToggle();
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||
isCustomEnabled && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
</div>
|
||||
|
||||
@@ -90,8 +90,8 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
className={cn(
|
||||
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
|
||||
'animate-popover-left z-50 ml-3 mt-6 flex min-w-[250px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary shadow-lg',
|
||||
)}
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
@@ -107,18 +107,16 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||
event.stopPropagation();
|
||||
handleShadcnToggle();
|
||||
}}
|
||||
disabled={isCustomEnabled}
|
||||
className={cn(
|
||||
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
||||
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||
isShadcnEnabled && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
@@ -131,15 +129,15 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||
handleCustomToggle();
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg px-2 py-2',
|
||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||
isCustomEnabled && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
</div>
|
||||
|
||||
@@ -251,6 +251,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
)}
|
||||
>
|
||||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||
{/* WIP */}
|
||||
<EditBadges
|
||||
isEditingChatBadges={isEditingBadges}
|
||||
handleCancelBadges={handleCancelBadges}
|
||||
|
||||
@@ -226,6 +226,7 @@ const AttachFileMenu = ({
|
||||
aria-label="Attach File Options"
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||
isPopoverActive && 'bg-surface-hover',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { ArrowUpDown, Database } from 'lucide-react';
|
||||
import { Database } from 'lucide-react';
|
||||
import { FileSources, FileContext } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
OpenAIMinimalIcon,
|
||||
AzureMinimalIcon,
|
||||
useMediaQuery,
|
||||
} from '@librechat/client';
|
||||
import { Checkbox, OpenAIMinimalIcon, AzureMinimalIcon, useMediaQuery } from '@librechat/client';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
|
||||
@@ -61,16 +55,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||
accessorKey: 'filename',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
{localize('com_ui_name')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
return <SortFilterHeader column={column} title={localize('com_ui_name')} />;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const file = row.original;
|
||||
@@ -100,16 +85,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||
accessorKey: 'updatedAt',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{localize('com_ui_date')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
return <SortFilterHeader column={column} title={localize('com_ui_date')} />;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
@@ -197,16 +173,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||
accessorKey: 'bytes',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
{localize('com_ui_size')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
return <SortFilterHeader column={column} title={localize('com_ui_size')} />;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const suffix = ' MB';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MultiSelect, MCPIcon } from '@librechat/client';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
function MCPSelectContent() {
|
||||
const { conversationId, mcpServerManager } = useBadgeRowContext();
|
||||
@@ -88,7 +89,10 @@ function MCPSelectContent() {
|
||||
className="badge-icon min-w-fit"
|
||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
selectClassName={cn(
|
||||
'group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all',
|
||||
'md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
)}
|
||||
/>
|
||||
{configDialogProps && (
|
||||
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
|
||||
|
||||
@@ -108,10 +108,10 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||
'w-full min-w-0 justify-between text-sm',
|
||||
isServerInitializing &&
|
||||
'opacity-50 hover:bg-transparent dark:hover:bg-transparent',
|
||||
isSelected && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-grow items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isSelected} />
|
||||
<span>{serverName}</span>
|
||||
</div>
|
||||
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function StreamAudio({ index = 0 }) {
|
||||
const { pauseGlobalAudio } = usePauseGlobalAudio();
|
||||
|
||||
const { conversationId: paramId } = useParams();
|
||||
const queryParam = paramId === 'new' ? paramId : latestMessage?.conversationId ?? paramId ?? '';
|
||||
const queryParam = paramId === 'new' ? paramId : (latestMessage?.conversationId ?? paramId ?? '');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const getMessages = useCallback(
|
||||
|
||||
@@ -307,10 +307,11 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
aria-label="Tools Options"
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||
isPopoverActive && 'bg-surface-hover',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<Settings2 className="icon-md" />
|
||||
<Settings2 className="size-5" />
|
||||
</div>
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export const data: TModelSpec[] = [
|
||||
iconURL: EModelEndpoint.openAI, // Allow using project-included icons
|
||||
preset: {
|
||||
chatGptLabel: 'Vision Helper',
|
||||
greeting: 'What\'s up!!',
|
||||
greeting: "What's up!!",
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4-turbo',
|
||||
promptPrefix:
|
||||
|
||||
@@ -55,7 +55,7 @@ const MenuItem: FC<MenuItemProps> = ({
|
||||
>
|
||||
<div className="flex grow items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className={cn('flex items-center gap-1 ')}>
|
||||
<div className={cn('flex items-center gap-1')}>
|
||||
{icon != null ? icon : null}
|
||||
<div className={cn('truncate', textClassName)}>
|
||||
{title}
|
||||
@@ -72,7 +72,7 @@ const MenuItem: FC<MenuItemProps> = ({
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block "
|
||||
className="icon-md block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function ProgressCircle({
|
||||
className="absolute left-1/2 top-1/2 h-[23px] w-[23px] -translate-x-1/2 -translate-y-1/2 text-brand-purple"
|
||||
>
|
||||
<circle
|
||||
className="origin-[50%_50%] -rotate-90 stroke-brand-purple/25 dark:stroke-brand-purple/50"
|
||||
className="stroke-brand-purple/25 dark:stroke-brand-purple/50 origin-[50%_50%] -rotate-90"
|
||||
strokeWidth="7.826086956521739"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MessageRender from './ui/MessageRender';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
|
||||
'group relative flex h-12 w-full items-center rounded-lg md:h-9',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
||||
)}
|
||||
role="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import type { TMessageProps } from '~/common';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
|
||||
import ContentRender from './ContentRender';
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function MessageContent(props: TMessageProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<ContentRender {...props} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ function AccountSettings() {
|
||||
<Select.Select
|
||||
aria-label={localize('com_nav_account_settings')}
|
||||
data-testid="nav-user"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover aria-[expanded=true]:bg-surface-hover"
|
||||
>
|
||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||
<div className="relative flex">
|
||||
@@ -40,11 +40,10 @@ function AccountSettings() {
|
||||
</div>
|
||||
</Select.Select>
|
||||
<Select.SelectPopover
|
||||
className="popover-ui w-[235px]"
|
||||
className="popover-ui w-[305px] rounded-lg md:w-[235px]"
|
||||
style={{
|
||||
transformOrigin: 'bottom',
|
||||
marginRight: '0px',
|
||||
translate: '0px',
|
||||
translate: '0 -4px',
|
||||
}}
|
||||
>
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import { Skeleton, useMediaQuery } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
|
||||
@@ -158,13 +158,12 @@ const Nav = memo(
|
||||
const headerButtons = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<Skeleton className="h-10 w-10 rounded-xl" />}>
|
||||
<AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} />
|
||||
</Suspense>
|
||||
{hasAccessToBookmarks && (
|
||||
<>
|
||||
<div className="mt-1.5" />
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<Skeleton className="h-10 w-10 rounded-xl" />}>
|
||||
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
|
||||
</Suspense>
|
||||
</>
|
||||
@@ -229,7 +228,7 @@ const Nav = memo(
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
</nav>
|
||||
|
||||
@@ -118,11 +118,16 @@ function ImportConversations() {
|
||||
aria-labelledby="import-conversation-label"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Spinner className="mr-1 w-4" />
|
||||
<>
|
||||
<Spinner className="mr-1 w-4" />
|
||||
<span>{localize('com_ui_importing')}</span>
|
||||
</>
|
||||
) : (
|
||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||
<>
|
||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</>
|
||||
)}
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TrashIcon, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import { TrashIcon, MessageSquare } from 'lucide-react';
|
||||
import {
|
||||
OGDialog,
|
||||
useToastContext,
|
||||
@@ -13,89 +10,162 @@ import {
|
||||
useMediaQuery,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
TooltipAnchor,
|
||||
DataTable,
|
||||
Spinner,
|
||||
Button,
|
||||
Label,
|
||||
} from '@librechat/client';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { formatDate } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
import { formatDate, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DEFAULT_PARAMS: SharedLinksListParams = {
|
||||
pageSize: PAGE_SIZE,
|
||||
pageSize: 25,
|
||||
isPublic: true,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
search: '',
|
||||
};
|
||||
|
||||
type SortKey = 'createdAt' | 'title';
|
||||
const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title';
|
||||
|
||||
const defaultSort: SortingState = [
|
||||
{
|
||||
id: 'createdAt',
|
||||
desc: true,
|
||||
},
|
||||
];
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
className?: string;
|
||||
desktopOnly?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SharedLinks() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const isSearchEnabled = useRecoilValue(store.search);
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||
useSharedLinksQuery(queryParams, {
|
||||
enabled: isOpen,
|
||||
staleTime: 0,
|
||||
cacheTime: 5 * 60 * 1000,
|
||||
keepPreviousData: true,
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||
const [allKnownLinks, setAllKnownLinks] = useState<SharedLinkItem[]>([]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setAllKnownLinks([]);
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: sortField as 'title' | 'createdAt',
|
||||
sortDirection: sortOrder,
|
||||
search: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: encodedValue,
|
||||
}));
|
||||
}, []);
|
||||
const handleSortingChange = useCallback(
|
||||
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||
setSorting((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
|
||||
const debouncedFilterChange = useMemo(
|
||||
() => debounce(handleFilterChange, 300),
|
||||
[handleFilterChange],
|
||||
const coerced = next;
|
||||
const primary = coerced[0];
|
||||
|
||||
if (data?.pages) {
|
||||
const currentFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []);
|
||||
setAllKnownLinks(currentFlattened);
|
||||
}
|
||||
|
||||
setQueryParams((p) => {
|
||||
let sortBy: SortKey;
|
||||
let sortDirection: 'asc' | 'desc';
|
||||
|
||||
if (primary && isSortKey(primary.id)) {
|
||||
sortBy = primary.id;
|
||||
sortDirection = primary.desc ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy = 'createdAt';
|
||||
sortDirection = 'desc';
|
||||
}
|
||||
|
||||
const newParams = {
|
||||
...p,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
};
|
||||
|
||||
return newParams;
|
||||
});
|
||||
|
||||
return coerced;
|
||||
});
|
||||
},
|
||||
[setQueryParams, data?.pages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedFilterChange.cancel();
|
||||
};
|
||||
}, [debouncedFilterChange]);
|
||||
if (!data?.pages) return;
|
||||
|
||||
const allLinks = useMemo(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
const newFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []);
|
||||
|
||||
const toAdd = newFlattened.filter(
|
||||
(link: SharedLinkItem) => !allKnownLinks.some((known) => known.shareId === link.shareId),
|
||||
);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
setAllKnownLinks((prev) => [...prev, ...toAdd]);
|
||||
}
|
||||
|
||||
return data.pages.flatMap((page) => page.links.filter(Boolean));
|
||||
}, [data?.pages]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
const primary = sorting[0];
|
||||
if (!primary || allKnownLinks.length === 0) return allKnownLinks;
|
||||
|
||||
return [...allKnownLinks].sort((a: SharedLinkItem, b: SharedLinkItem) => {
|
||||
let compare: number;
|
||||
if (primary.id === 'createdAt') {
|
||||
const aDate = new Date(a.createdAt || 0);
|
||||
const bDate = new Date(b.createdAt || 0);
|
||||
compare = aDate.getTime() - bDate.getTime();
|
||||
} else if (primary.id === 'title') {
|
||||
compare = (a.title || '').localeCompare(b.title || '');
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
return primary.desc ? -compare : compare;
|
||||
});
|
||||
}, [allKnownLinks, sorting]);
|
||||
|
||||
const deleteMutation = useDeleteSharedLinkMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccess: (data, variables) => {
|
||||
const { shareId } = variables;
|
||||
setAllKnownLinks((prev) => prev.filter((link) => link.shareId !== shareId));
|
||||
showToast({
|
||||
message: localize('com_ui_shared_link_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteRow(null);
|
||||
await refetch();
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Delete error:', error);
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
@@ -103,94 +173,47 @@ export default function SharedLinks() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (selectedRows: SharedLinkItem[]) => {
|
||||
const validRows = selectedRows.filter(
|
||||
(row) => typeof row.shareId === 'string' && row.shareId.length > 0,
|
||||
);
|
||||
|
||||
if (validRows.length === 0) {
|
||||
showToast({
|
||||
message: localize('com_ui_no_valid_items'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const row of validRows) {
|
||||
await deleteMutation.mutateAsync({ shareId: row.shareId });
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: localize(
|
||||
validRows.length === 1
|
||||
? 'com_ui_shared_link_delete_success'
|
||||
: 'com_ui_shared_link_bulk_delete_success',
|
||||
),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete shared links:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_bulk_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
[deleteMutation, showToast, localize],
|
||||
);
|
||||
|
||||
const handleFetchNextPage = useCallback(async () => {
|
||||
if (hasNextPage !== true || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (deleteRow) {
|
||||
handleDelete([deleteRow]);
|
||||
}
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteRow, handleDelete]);
|
||||
const effectiveIsLoading = isLoading && displayData.length === 0;
|
||||
const effectiveIsFetching = isFetchingNextPage;
|
||||
|
||||
const columns = useMemo(
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (!deleteRow?.shareId) {
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate({ shareId: deleteRow.shareId });
|
||||
}, [deleteMutation, deleteRow, localize, showToast]);
|
||||
|
||||
const columns: TableColumn<Record<string, unknown>, unknown>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'title';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_ui_name')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
</Button>
|
||||
);
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const link = row as SharedLinkItem;
|
||||
return link.title;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">{localize('com_ui_name')}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { title, shareId } = row.original;
|
||||
const link = row.original as SharedLinkItem;
|
||||
const { title, shareId } = link;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/share/${shareId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block truncate text-blue-500 hover:underline"
|
||||
title={title}
|
||||
className="flex items-center truncate text-blue-500 hover:underline"
|
||||
aria-label={localize('com_ui_open_link', { 0: title })}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
@@ -198,112 +221,123 @@ export default function SharedLinks() {
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
size: '35%',
|
||||
mobileSize: '50%',
|
||||
className: 'min-w-[150px] flex-1',
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'createdAt';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_ui_date')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
</Button>
|
||||
);
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const link = row as SharedLinkItem;
|
||||
return link.createdAt;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">{localize('com_ui_date')}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const link = row.original as SharedLinkItem;
|
||||
return formatDate(link.createdAt?.toString() ?? '', isSmallScreen);
|
||||
},
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
meta: {
|
||||
size: '10%',
|
||||
mobileSize: '20%',
|
||||
className: 'w-32 sm:w-40',
|
||||
desktopOnly: true,
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
id: 'actions',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => null,
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm">
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
size: '7%',
|
||||
mobileSize: '25%',
|
||||
cell: ({ row }) => {
|
||||
const link = row.original as SharedLinkItem;
|
||||
const { title, conversationId } = link;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_view_source')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
window.open(`/c/${conversationId}`, '_blank');
|
||||
}}
|
||||
aria-label={localize('com_ui_view_source_conversation', { 0: title })}
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setDeleteRow(link);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
aria-label={localize('com_ui_delete_link_title', { 0: title })}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
window.open(`/c/${row.original.conversationId}`, '_blank');
|
||||
}}
|
||||
aria-label={`${localize('com_ui_view_source')} - ${row.original.title || localize('com_ui_untitled')}`}
|
||||
>
|
||||
<MessageSquare className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
aria-label={`${localize('com_ui_delete')} - ${row.original.title || localize('com_ui_untitled')}`}
|
||||
>
|
||||
<TrashIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-24',
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize, queryParams, handleSort],
|
||||
[isSmallScreen, localize],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
|
||||
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button aria-labelledby="shared-links-label" variant="outline">
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent
|
||||
title={localize('com_nav_my_files')}
|
||||
className="w-11/12 max-w-5xl bg-background text-text-primary shadow-2xl"
|
||||
>
|
||||
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-1 pb-1')}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={allLinks}
|
||||
onDelete={handleDelete}
|
||||
filterColumn="title"
|
||||
data={displayData}
|
||||
isLoading={effectiveIsLoading}
|
||||
isFetching={effectiveIsFetching}
|
||||
config={{
|
||||
skeleton: { count: 11 },
|
||||
search: {
|
||||
filterColumn: 'title',
|
||||
enableSearch: true,
|
||||
debounce: 300,
|
||||
},
|
||||
selection: {
|
||||
enableRowSelection: false,
|
||||
showCheckboxes: false,
|
||||
},
|
||||
}}
|
||||
filterValue={searchValue}
|
||||
onFilterChange={handleSearchChange}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
showCheckboxes={false}
|
||||
onFilterChange={debouncedFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
isLoading={isLoading}
|
||||
enableSearch={isSearchEnabled}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
@@ -311,17 +345,15 @@ export default function SharedLinks() {
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_shared_link')}
|
||||
className="max-w-[450px]"
|
||||
className="w-11/12 max-w-md"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
|
||||
@@ -1,26 +1,406 @@
|
||||
import { useState } from 'react';
|
||||
import { OGDialogTemplate, OGDialog, OGDialogTrigger, Button } from '@librechat/client';
|
||||
import ArchivedChatsTable from './ArchivedChatsTable';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { TrashIcon, ArchiveRestore } from 'lucide-react';
|
||||
import { useQueryClient, InfiniteData } from '@tanstack/react-query';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
OGDialogTemplate,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
Label,
|
||||
TooltipAnchor,
|
||||
Spinner,
|
||||
useToastContext,
|
||||
useMediaQuery,
|
||||
DataTable,
|
||||
type TableColumn,
|
||||
} from '@librechat/client';
|
||||
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import {
|
||||
useArchiveConvoMutation,
|
||||
useConversationsInfiniteQuery,
|
||||
useDeleteConversationMutation,
|
||||
} from '~/data-provider';
|
||||
import { MinimalIcon } from '~/components/Endpoints';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { formatDate, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function ArchivedChats() {
|
||||
const DEFAULT_PARAMS = {
|
||||
isArchived: true,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
search: '',
|
||||
} as const satisfies ConversationListParams;
|
||||
|
||||
type SortKey = 'createdAt' | 'title';
|
||||
const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title';
|
||||
|
||||
const defaultSort: SortingState = [
|
||||
{
|
||||
id: 'createdAt',
|
||||
desc: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Helper: remove a conversation from all infinite queries whose key starts with the provided root
|
||||
*/
|
||||
function removeConversationFromInfinite(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
rootKey: string,
|
||||
conversationId: string,
|
||||
) {
|
||||
const queries = queryClient.getQueryCache().findAll([rootKey], { exact: false });
|
||||
for (const query of queries) {
|
||||
queryClient.setQueryData<
|
||||
InfiniteData<{ conversations: TConversation[]; nextCursor?: string | null }>
|
||||
>(query.queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
conversations: page.conversations.filter((c) => c.conversationId !== conversationId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function ArchivedChatsTable() {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const { showToast } = useToastContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteRow, setDeleteRow] = useState<TConversation | null>(null);
|
||||
const [unarchivingId, setUnarchivingId] = useState<string | null>(null);
|
||||
|
||||
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
useConversationsInfiniteQuery(queryParams, {
|
||||
enabled: isOpen,
|
||||
keepPreviousData: false,
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSortingChange = useCallback(
|
||||
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||
setSorting((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
const primary = next[0];
|
||||
setQueryParams((p) => {
|
||||
let sortBy: SortKey = 'createdAt';
|
||||
let sortDirection: 'asc' | 'desc' = 'desc';
|
||||
if (primary && isSortKey(primary.id)) {
|
||||
sortBy = primary.id;
|
||||
sortDirection = primary.desc ? 'desc' : 'asc';
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
};
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const flattenedConversations = useMemo(
|
||||
() => data?.pages?.flatMap((page) => page?.conversations?.filter(Boolean) ?? []) ?? [],
|
||||
[data?.pages],
|
||||
);
|
||||
|
||||
const unarchiveMutation = useArchiveConvoMutation({
|
||||
onSuccess: (_res, variables) => {
|
||||
const { conversationId } = variables;
|
||||
if (conversationId) {
|
||||
removeConversationFromInfinite(
|
||||
queryClient,
|
||||
QueryKeys.archivedConversations,
|
||||
conversationId,
|
||||
);
|
||||
}
|
||||
queryClient.invalidateQueries([QueryKeys.allConversations]);
|
||||
setUnarchivingId(null);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_unarchive_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
setUnarchivingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useDeleteConversationMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
const { conversationId } = variables;
|
||||
if (conversationId) {
|
||||
removeConversationFromInfinite(
|
||||
queryClient,
|
||||
QueryKeys.archivedConversations,
|
||||
conversationId,
|
||||
);
|
||||
}
|
||||
showToast({
|
||||
message: localize('com_ui_archived_conversation_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
setIsDeleteOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_archive_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleFetchNextPage = useCallback(async () => {
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const effectiveIsLoading = isLoading;
|
||||
const effectiveIsFetching = isFetchingNextPage;
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (!deleteRow?.conversationId) {
|
||||
showToast({
|
||||
message: localize('com_ui_convo_delete_error'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate({ conversationId: deleteRow.conversationId });
|
||||
}, [deleteMutation, deleteRow, localize, showToast]);
|
||||
|
||||
const handleUnarchive = useCallback(
|
||||
(conversationId: string) => {
|
||||
setUnarchivingId(conversationId);
|
||||
unarchiveMutation.mutate(
|
||||
{ conversationId, isArchived: false },
|
||||
{ onSettled: () => setUnarchivingId(null) },
|
||||
);
|
||||
},
|
||||
[unarchiveMutation],
|
||||
);
|
||||
|
||||
const columns: TableColumn<Record<string, unknown>, unknown>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const convo = row as TConversation;
|
||||
return convo.title;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_nav_archive_name')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const convo = row.original as TConversation;
|
||||
const { conversationId, title } = convo;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<MinimalIcon
|
||||
endpoint={convo.endpoint}
|
||||
size={28}
|
||||
isCreatedByUser={false}
|
||||
iconClassName="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<a
|
||||
href={`/c/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center truncate underline"
|
||||
aria-label={localize('com_ui_open_conversation', { 0: title })}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: 'min-w-[150px] flex-1',
|
||||
isRowHeader: true,
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const convo = row as TConversation;
|
||||
return convo.createdAt;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_nav_archive_created_at')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const convo = row.original as TConversation;
|
||||
return formatDate(convo.createdAt?.toString() ?? '', isSmallScreen);
|
||||
},
|
||||
meta: {
|
||||
className: 'w-32 sm:w-40',
|
||||
desktopOnly: true,
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
accessorFn: () => null,
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const convo = row.original as TConversation;
|
||||
const { title } = convo;
|
||||
const isRowUnarchiving = unarchivingId === convo.conversationId;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_unarchive')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 w-9 p-0 hover:bg-surface-hover md:h-8 md:w-8"
|
||||
onClick={() => {
|
||||
const conversationId = convo.conversationId;
|
||||
if (!conversationId) return;
|
||||
handleUnarchive(conversationId);
|
||||
}}
|
||||
disabled={isRowUnarchiving}
|
||||
aria-label={localize('com_ui_unarchive_conversation_title', { 0: title })}
|
||||
>
|
||||
{isRowUnarchiving ? <Spinner /> : <ArchiveRestore className="size-4" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 w-9 p-0 md:h-8 md:w-8"
|
||||
onClick={() => {
|
||||
setDeleteRow(convo);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
aria-label={localize('com_ui_delete_conversation_title', { 0: title })}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: 'w-24',
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize, handleUnarchive, unarchivingId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_archived_chats')}</div>
|
||||
<Label htmlFor="archived-chats-button" className="text-sm font-medium">
|
||||
{localize('com_nav_archived_chats')}
|
||||
</Label>
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline" aria-label="Archived chats">
|
||||
<Button
|
||||
id="archived-chats-button"
|
||||
variant="outline"
|
||||
aria-label={localize('com_ui_manage_archived_chats')}
|
||||
>
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-1 pb-1')}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={flattenedConversations}
|
||||
isLoading={effectiveIsLoading}
|
||||
isFetching={effectiveIsFetching}
|
||||
config={{
|
||||
skeleton: { count: 11 },
|
||||
search: {
|
||||
filterColumn: 'title',
|
||||
enableSearch: true,
|
||||
debounce: 300,
|
||||
},
|
||||
selection: {
|
||||
enableRowSelection: false,
|
||||
showCheckboxes: false,
|
||||
},
|
||||
}}
|
||||
filterValue={searchValue}
|
||||
onFilterChange={handleSearchChange}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_nav_archived_chats')}
|
||||
className="max-w-[1000px]"
|
||||
showCancelButton={false}
|
||||
main={<ArchivedChatsTable isOpen={isOpen} onOpenChange={setIsOpen} />}
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_archived_chats')}
|
||||
className="w-11/12 max-w-md"
|
||||
main={
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
|
||||
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
|
||||
}`,
|
||||
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
Label,
|
||||
TooltipAnchor,
|
||||
Spinner,
|
||||
DataTable,
|
||||
useToastContext,
|
||||
useMediaQuery,
|
||||
} from '@librechat/client';
|
||||
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||
import {
|
||||
useArchiveConvoMutation,
|
||||
useConversationsInfiniteQuery,
|
||||
useDeleteConversationMutation,
|
||||
} from '~/data-provider';
|
||||
import { MinimalIcon } from '~/components/Endpoints';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { formatDate } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const DEFAULT_PARAMS: ConversationListParams = {
|
||||
isArchived: true,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
search: '',
|
||||
};
|
||||
|
||||
export default function ArchivedChatsTable({
|
||||
onOpenChange,
|
||||
}: {
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const { showToast } = useToastContext();
|
||||
const isSearchEnabled = useRecoilValue(store.search);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
|
||||
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||
useConversationsInfiniteQuery(queryParams, {
|
||||
staleTime: 0,
|
||||
cacheTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: sortField as 'title' | 'createdAt',
|
||||
sortDirection: sortOrder,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: encodedValue,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const debouncedFilterChange = useMemo(
|
||||
() => debounce(handleFilterChange, 300),
|
||||
[handleFilterChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedFilterChange.cancel();
|
||||
};
|
||||
}, [debouncedFilterChange]);
|
||||
|
||||
const allConversations = useMemo(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
}
|
||||
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
|
||||
}, [data?.pages]);
|
||||
|
||||
const deleteMutation = useDeleteConversationMutation({
|
||||
onSuccess: async () => {
|
||||
setIsDeleteOpen(false);
|
||||
await refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
message: localize('com_ui_archive_delete_error') as string,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const unarchiveMutation = useArchiveConvoMutation({
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
message: localize('com_ui_unarchive_error') as string,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleFetchNextPage = useCallback(async () => {
|
||||
if (!hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'title';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_nav_archive_name')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const { conversationId, title } = row.original;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 truncate"
|
||||
onClick={() => window.open(`/c/${conversationId}`, '_blank')}
|
||||
>
|
||||
<MinimalIcon
|
||||
endpoint={row.original.endpoint}
|
||||
size={28}
|
||||
isCreatedByUser={false}
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
<span className="underline">{title}</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
size: isSmallScreen ? '70%' : '50%',
|
||||
mobileSize: '70%',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'createdAt';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_nav_archive_created_at')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
meta: {
|
||||
size: isSmallScreen ? '30%' : '35%',
|
||||
mobileSize: '30%',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const conversation = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_unarchive')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() =>
|
||||
unarchiveMutation.mutate({
|
||||
conversationId: conversation.conversationId,
|
||||
isArchived: false,
|
||||
})
|
||||
}
|
||||
title={localize('com_ui_unarchive')}
|
||||
disabled={unarchiveMutation.isLoading}
|
||||
>
|
||||
{unarchiveMutation.isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<ArchiveRestore className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
setDeleteConversation(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
title={localize('com_ui_delete')}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
size: '15%',
|
||||
mobileSize: '25%',
|
||||
},
|
||||
},
|
||||
],
|
||||
[handleSort, isSmallScreen, localize, queryParams, unarchiveMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={allConversations}
|
||||
filterColumn="title"
|
||||
onFilterChange={debouncedFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isLoading={isLoading}
|
||||
showCheckboxes={false}
|
||||
enableSearch={isSearchEnabled}
|
||||
/>
|
||||
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
title={localize('com_ui_delete_confirm') + ' ' + (deleteConversation?.title ?? '')}
|
||||
className="w-11/12 max-w-md"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteConversation?.title}</strong>
|
||||
</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
<Button aria-label="cancel" variant="outline" onClick={() => setIsDeleteOpen(false)}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
deleteMutation.mutate({
|
||||
conversationId: deleteConversation?.conversationId ?? '',
|
||||
})
|
||||
}
|
||||
disabled={deleteMutation.isLoading}
|
||||
>
|
||||
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default function OAuthSuccess() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
|
||||
<h1 className="mb-4 text-3xl font-bold text-gray-900">
|
||||
{localize('com_ui_oauth_success_title') || 'Authentication Successful'}
|
||||
</h1>
|
||||
|
||||
@@ -187,8 +187,7 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
|
||||
value={searchValue}
|
||||
onChange={handleSearch}
|
||||
placeholder={localize('com_nav_plugin_search')}
|
||||
className="
|
||||
text-token-text-primary flex rounded-md border border-border-heavy bg-surface-tertiary py-2 pl-10 pr-2"
|
||||
className="text-token-text-primary flex rounded-md border border-border-heavy bg-surface-tertiary py-2 pl-10 pr-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,9 @@ type TPluginTooltipProps = {
|
||||
function PluginTooltip({ content, position }: TPluginTooltipProps) {
|
||||
return (
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={position} className="w-80 ">
|
||||
<HoverCardContent side={position} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{content}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">{content}</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import Message from './Message';
|
||||
import store from '~/store';
|
||||
|
||||
|
||||
@@ -33,9 +33,7 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-token-border-medium flex rounded-lg border text-sm hover:cursor-pointer">
|
||||
<div className="h-9 grow px-3 py-2">
|
||||
{localize(getAuthLocalizationKey(type))}
|
||||
</div>
|
||||
<div className="h-9 grow px-3 py-2">{localize(getAuthLocalizationKey(type))}</div>
|
||||
<div className="bg-token-border-medium w-px"></div>
|
||||
<button type="button" color="neutral" className="flex items-center gap-2 px-3">
|
||||
<svg
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function useAddedHelpers({
|
||||
store.messagesSiblingIdxFamily(latestMessage?.parentMessageId ?? null),
|
||||
);
|
||||
|
||||
const queryParam = paramId === 'new' ? paramId : conversation?.conversationId ?? paramId ?? '';
|
||||
const queryParam = paramId === 'new' ? paramId : (conversation?.conversationId ?? paramId ?? '');
|
||||
|
||||
const setMessages = useCallback(
|
||||
(messages: TMessage[]) => {
|
||||
|
||||
@@ -33,9 +33,8 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont
|
||||
|
||||
const _messages = getMessages();
|
||||
const messages =
|
||||
_messages
|
||||
?.filter((m) => m.messageId !== messageId)
|
||||
.map((msg) => ({ ...msg, thread_id })) ?? [];
|
||||
_messages?.filter((m) => m.messageId !== messageId).map((msg) => ({ ...msg, thread_id })) ??
|
||||
[];
|
||||
const userMessage = messages[messages.length - 1] as TMessage | undefined;
|
||||
|
||||
const { initialResponse } = submission;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"chat_direction_left_to_right": "Left to Right",
|
||||
"chat_direction_right_to_left": "Right to Left",
|
||||
"chat_direction_left_to_right": "Chat direction set to left to right",
|
||||
"chat_direction_right_to_left": "Chat direction set to right to left",
|
||||
"com_a11y_ai_composing": "The AI is still composing.",
|
||||
"com_a11y_end": "The AI has finished their reply.",
|
||||
"com_a11y_start": "The AI has started their reply.",
|
||||
@@ -388,10 +388,10 @@
|
||||
"com_files_filter": "Filter files...",
|
||||
"com_files_filter_by": "Filter files by...",
|
||||
"com_files_no_results": "No results.",
|
||||
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",
|
||||
"com_files_number_selected": "{{0}} of {{1}} items selected",
|
||||
"com_files_preparing_download": "Preparing download...",
|
||||
"com_files_sharepoint_picker_title": "Pick Files",
|
||||
"com_files_table": "something needs to go here. was empty",
|
||||
"com_files_table": "Files Table",
|
||||
"com_files_upload_local_machine": "From Local Computer",
|
||||
"com_files_upload_sharepoint": "From SharePoint",
|
||||
"com_generated_files": "Generated files:",
|
||||
@@ -403,6 +403,7 @@
|
||||
"com_nav_archive_created_at": "Date Archived",
|
||||
"com_nav_archive_name": "Name",
|
||||
"com_nav_archived_chats": "Archived chats",
|
||||
"com_ui_manage_archived_chats": "Manage archived chats",
|
||||
"com_nav_at_command": "@-Command",
|
||||
"com_nav_at_command_description": "Toggle command \"@\" for switching endpoints, models, presets, etc.",
|
||||
"com_nav_audio_play_error": "Error playing audio: {{0}}",
|
||||
@@ -762,7 +763,6 @@
|
||||
"com_ui_bookmarks_title": "Title",
|
||||
"com_ui_bookmarks_update_error": "There was an error updating the bookmark",
|
||||
"com_ui_bookmarks_update_success": "Bookmark updated successfully",
|
||||
"com_ui_bulk_delete_error": "Failed to delete shared links",
|
||||
"com_ui_callback_url": "Callback URL",
|
||||
"com_ui_cancel": "Cancel",
|
||||
"com_ui_cancelled": "Cancelled",
|
||||
@@ -848,6 +848,7 @@
|
||||
"com_ui_delete_not_allowed": "Delete operation is not allowed",
|
||||
"com_ui_delete_prompt": "Delete Prompt?",
|
||||
"com_ui_delete_shared_link": "Delete shared link?",
|
||||
"com_ui_delete_archived_chats": "Delete archived chat?",
|
||||
"com_ui_delete_success": "Successfully deleted",
|
||||
"com_ui_delete_tool": "Delete Tool",
|
||||
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
||||
@@ -973,6 +974,7 @@
|
||||
"com_ui_image_edited": "Image edited",
|
||||
"com_ui_image_gen": "Image Gen",
|
||||
"com_ui_import": "Import",
|
||||
"com_ui_importing": "Importing",
|
||||
"com_ui_import_conversation_error": "There was an error importing your conversations",
|
||||
"com_ui_import_conversation_file_type_error": "Unsupported import type",
|
||||
"com_ui_import_conversation_info": "Import conversations from a JSON file",
|
||||
@@ -1058,7 +1060,6 @@
|
||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
"com_ui_no_terms_content": "No terms and conditions content to display",
|
||||
"com_ui_no_valid_items": "something needs to go here. was empty",
|
||||
"com_ui_none": "None",
|
||||
"com_ui_not_used": "Not Used",
|
||||
"com_ui_nothing_found": "Nothing found",
|
||||
@@ -1201,9 +1202,12 @@
|
||||
"com_ui_share_qr_code_description": "QR code for sharing this conversation link",
|
||||
"com_ui_share_update_message": "Your name, custom instructions, and any messages you add after sharing stay private.",
|
||||
"com_ui_share_var": "Share {{0}}",
|
||||
"com_ui_shared_link_bulk_delete_success": "Successfully deleted shared links",
|
||||
"com_ui_shared_link_delete_success": "Successfully deleted shared link",
|
||||
"com_ui_archived_conversation_delete_success": "Successfully deleted archived conversation",
|
||||
"com_ui_shared_link_not_found": "Shared link not found",
|
||||
"com_ui_open_link": "Open Link {{0}}",
|
||||
"com_ui_view_source_conversation": "View Source Conversation {{0}}",
|
||||
"com_ui_delete_link_title": "Delete Shared Link {{0}}",
|
||||
"com_ui_shared_prompts": "Shared Prompts",
|
||||
"com_ui_shop": "Shopping",
|
||||
"com_ui_show_all": "Show All",
|
||||
@@ -1327,5 +1331,8 @@
|
||||
"com_ui_zoom_in": "Zoom in",
|
||||
"com_ui_zoom_level": "Zoom level",
|
||||
"com_ui_zoom_out": "Zoom out",
|
||||
"com_ui_open_conversation": "Open conversation {{0}}",
|
||||
"com_ui_delete_conversation_title": "Delete conversation {{0}}",
|
||||
"com_ui_unarchive_conversation_title": "Unarchive conversation {{0}}",
|
||||
"com_user_message": "You"
|
||||
}
|
||||
|
||||
@@ -2581,7 +2581,7 @@ html {
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
border-radius: 1rem;
|
||||
border-radius: 0.7rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--border-light);
|
||||
@@ -2654,6 +2654,7 @@ html {
|
||||
translate: 0;
|
||||
}
|
||||
|
||||
.animate-popover-top,
|
||||
.animate-popover {
|
||||
transform-origin: top;
|
||||
opacity: 0;
|
||||
@@ -2662,12 +2663,13 @@ html {
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translateY(-0.5rem);
|
||||
}
|
||||
|
||||
.animate-popover-top[data-enter],
|
||||
.animate-popover[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
/* Left (existing) */
|
||||
.animate-popover-left {
|
||||
transform-origin: left;
|
||||
opacity: 0;
|
||||
@@ -2676,12 +2678,92 @@ html {
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translateX(-0.5rem);
|
||||
}
|
||||
|
||||
.animate-popover-left[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateX(0);
|
||||
}
|
||||
|
||||
/* Right */
|
||||
.animate-popover-right {
|
||||
transform-origin: right;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translateX(0.5rem);
|
||||
}
|
||||
.animate-popover-right[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateX(0);
|
||||
}
|
||||
|
||||
/* Bottom */
|
||||
.animate-popover-bottom {
|
||||
transform-origin: bottom;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translateY(0.5rem);
|
||||
}
|
||||
.animate-popover-bottom[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
/* Corners */
|
||||
.animate-popover-top-left {
|
||||
transform-origin: top left;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translate(-0.5rem, -0.5rem);
|
||||
}
|
||||
.animate-popover-top-left[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translate(0, 0);
|
||||
}
|
||||
|
||||
.animate-popover-top-right {
|
||||
transform-origin: top right;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translate(0.5rem, -0.5rem);
|
||||
}
|
||||
.animate-popover-top-right[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translate(0, 0);
|
||||
}
|
||||
|
||||
.animate-popover-bottom-left {
|
||||
transform-origin: bottom left;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translate(-0.5rem, 0.5rem);
|
||||
}
|
||||
.animate-popover-bottom-left[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translate(0, 0);
|
||||
}
|
||||
|
||||
.animate-popover-bottom-right {
|
||||
transform-origin: bottom right;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translate(0.5rem, 0.5rem);
|
||||
}
|
||||
.animate-popover-bottom-right[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translate(0, 0);
|
||||
}
|
||||
|
||||
/** Note: ensure KaTeX can spread across visible space */
|
||||
.message-content pre:has(> span.katex) {
|
||||
overflow: visible !important;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { EModelEndpoint, ImageDetail } from 'librechat-data-provider';
|
||||
import type { ConversationData } from 'librechat-data-provider';
|
||||
@@ -98,7 +97,7 @@ export const convoData: ConversationData = {
|
||||
promptPrefix: null,
|
||||
resendFiles: false,
|
||||
temperature: 1,
|
||||
title: 'Write Einstein\'s Famous Equation in LaTeX',
|
||||
title: "Write Einstein's Famous Equation in LaTeX",
|
||||
top_p: 1,
|
||||
updatedAt,
|
||||
},
|
||||
|
||||
@@ -62,7 +62,7 @@ const shouldRebase = process.argv.includes('--rebase');
|
||||
console.green('Your LibreChat app is now up to date! Start the app with the following command:');
|
||||
console.purple(startCommand);
|
||||
console.orange(
|
||||
'Note: it\'s also recommended to clear your browser cookies and localStorage for LibreChat to assure a fully clean installation.',
|
||||
"Note: it's also recommended to clear your browser cookies and localStorage for LibreChat to assure a fully clean installation.",
|
||||
);
|
||||
console.orange('Also: Don\'t worry, your data is safe :)');
|
||||
console.orange("Also: Don't worry, your data is safe :)");
|
||||
})();
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -45897,7 +45897,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@librechat/client",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
|
||||
@@ -176,11 +176,14 @@ export function formatToolContent(
|
||||
formattedContent.push({ type: 'text', text: currentTextBlock });
|
||||
}
|
||||
|
||||
let artifacts: t.Artifacts = undefined;
|
||||
if (imageUrls.length || uiResources.length) {
|
||||
let artifacts: t.Artifacts = imageUrls.length ? { content: imageUrls } : undefined;
|
||||
|
||||
if (uiResources.length) {
|
||||
artifacts = {
|
||||
...(imageUrls.length && { content: imageUrls }),
|
||||
...(uiResources.length && { [Tools.ui_resources]: { data: uiResources } }),
|
||||
...artifacts,
|
||||
[Tools.ui_resources]: {
|
||||
data: uiResources,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/client",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"description": "React components for LibreChat",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
|
||||
@@ -26,6 +26,8 @@ const plugins = [
|
||||
}),
|
||||
replace({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||
'process.env.VITE_ENABLE_LOGGER': JSON.stringify(process.env.VITE_ENABLE_LOGGER || 'false'),
|
||||
'process.env.VITE_LOGGER_FILTER': JSON.stringify(process.env.VITE_LOGGER_FILTER || ''),
|
||||
preventAssignment: true,
|
||||
}),
|
||||
commonjs(),
|
||||
|
||||
@@ -25,7 +25,7 @@ const Checkbox = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-border-xheavy ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Row,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
VisibilityState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
import type { Table as TTable } from '@tanstack/react-table';
|
||||
import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table';
|
||||
import AnimatedSearchInput from './AnimatedSearchInput';
|
||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { TrashIcon, Spinner } from '~/svgs';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import { Button } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
size?: string | number;
|
||||
mobileSize?: string | number;
|
||||
minWidth?: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
const SelectionCheckbox = memo(
|
||||
({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
ariaLabel: string;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="flex h-full w-[30px] items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
data: TData[];
|
||||
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||
filterColumn?: string;
|
||||
defaultSort?: SortingState;
|
||||
columnVisibilityMap?: Record<string, string>;
|
||||
className?: string;
|
||||
pageSize?: number;
|
||||
isFetchingNextPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage?: (options?: unknown) => Promise<unknown>;
|
||||
enableRowSelection?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
onFilterChange?: (value: string) => void;
|
||||
filterValue?: string;
|
||||
isLoading?: boolean;
|
||||
enableSearch?: boolean;
|
||||
}
|
||||
|
||||
const TableRowComponent = <TData, TValue>({
|
||||
row,
|
||||
isSmallScreen,
|
||||
onSelectionChange,
|
||||
index,
|
||||
isSearching,
|
||||
}: {
|
||||
row: Row<TData>;
|
||||
isSmallScreen: boolean;
|
||||
onSelectionChange?: (rowId: string, selected: boolean) => void;
|
||||
index: number;
|
||||
isSearching: boolean;
|
||||
}) => {
|
||||
const handleSelection = useCallback(
|
||||
(value: boolean) => {
|
||||
row.toggleSelected(value);
|
||||
onSelectionChange?.(row.id, value);
|
||||
},
|
||||
[row, onSelectionChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className="motion-safe:animate-fadeIn border-b border-border-light transition-all duration-300 ease-out hover:bg-surface-secondary"
|
||||
style={{
|
||||
animationDelay: `${index * 20}ms`,
|
||||
transform: `translateY(${isSearching ? '4px' : '0'})`,
|
||||
opacity: isSearching ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
if (cell.column.id === 'select') {
|
||||
return (
|
||||
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={handleSelection}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (cell.column.id === 'title') {
|
||||
return (
|
||||
<TableHead
|
||||
key={cell.id}
|
||||
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
|
||||
style={getColumnStyle(
|
||||
cell.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
scope="row"
|
||||
>
|
||||
<div className="overflow-hidden text-ellipsis">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
|
||||
style={getColumnStyle(
|
||||
cell.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden text-ellipsis">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
|
||||
|
||||
function getColumnStyle<TData, TValue>(
|
||||
column: TableColumn<TData, TValue>,
|
||||
isSmallScreen: boolean,
|
||||
): React.CSSProperties {
|
||||
return {
|
||||
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
|
||||
minWidth: column.meta?.minWidth,
|
||||
maxWidth: column.meta?.size,
|
||||
};
|
||||
}
|
||||
|
||||
const DeleteButton = memo(
|
||||
({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
disabled,
|
||||
isSmallScreen,
|
||||
ariaLabel,
|
||||
}: {
|
||||
onDelete?: () => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
disabled: boolean;
|
||||
isSmallScreen: boolean;
|
||||
ariaLabel: string;
|
||||
}) => {
|
||||
if (!onDelete) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
|
||||
{!isSmallScreen && <span className="ml-2">Delete</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onDelete,
|
||||
filterColumn,
|
||||
defaultSort = [],
|
||||
className = '',
|
||||
isFetchingNextPage = false,
|
||||
hasNextPage = false,
|
||||
fetchNextPage,
|
||||
enableRowSelection = true,
|
||||
showCheckboxes = true,
|
||||
onFilterChange,
|
||||
filterValue,
|
||||
isLoading,
|
||||
enableSearch = true,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
if (!enableRowSelection || !showCheckboxes) {
|
||||
return columns;
|
||||
}
|
||||
const selectColumn = {
|
||||
id: 'select',
|
||||
header: ({ table }: { table: TTable<TData> }) => (
|
||||
<div className="flex h-full w-[30px] items-center justify-center">
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: Row<TData> }) => (
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={(value) => row.toggleSelected(value)}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
),
|
||||
meta: { size: '50px' },
|
||||
};
|
||||
return [selectColumn, ...columns];
|
||||
}, [columns, enableRowSelection, showCheckboxes]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
enableRowSelection,
|
||||
enableMultiRowSelection: true,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: useCallback(() => 48, []),
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = tableContainerRef.current;
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = async () => {
|
||||
if (!hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
|
||||
try {
|
||||
// Safely fetch next page without breaking if lastPage is undefined
|
||||
await fetchNextPage?.();
|
||||
} catch (error) {
|
||||
console.error('Unable to fetch next page:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSearching(true);
|
||||
const timeout = setTimeout(() => {
|
||||
onFilterChange?.(searchTerm);
|
||||
setIsSearching(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchTerm, onFilterChange]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!onDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
|
||||
await onDelete(itemsToDelete);
|
||||
setRowSelection({});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [onDelete, table]);
|
||||
|
||||
const getRandomWidth = () => Math.floor(Math.random() * (410 - 170 + 1)) + 170;
|
||||
|
||||
const skeletons = Array.from({ length: 13 }, (_, index) => {
|
||||
const randomWidth = getRandomWidth();
|
||||
const firstDataColumnIndex = tableColumns[0]?.id === 'select' ? 1 : 0;
|
||||
|
||||
return (
|
||||
<TableRow key={index} className="motion-safe:animate-fadeIn border-b border-border-light">
|
||||
{tableColumns.map((column, columnIndex) => {
|
||||
const style = getColumnStyle(column as TableColumn<TData, TValue>, isSmallScreen);
|
||||
const isFirstDataColumn = columnIndex === firstDataColumnIndex;
|
||||
|
||||
return (
|
||||
<TableCell key={column.id} className="px-2 py-1 sm:px-4 sm:py-2" style={style}>
|
||||
<Skeleton
|
||||
className="h-6"
|
||||
style={isFirstDataColumn ? { width: `${randomWidth}px` } : { width: '100%' }}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col gap-4', className)}>
|
||||
{/* Table controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
|
||||
{enableRowSelection && showCheckboxes && (
|
||||
<DeleteButton
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
isSmallScreen={isSmallScreen}
|
||||
ariaLabel={localize('com_ui_delete_selected_items')}
|
||||
/>
|
||||
)}
|
||||
{filterColumn !== undefined && table.getColumn(filterColumn) && enableSearch && (
|
||||
<div className="relative flex-1">
|
||||
<AnimatedSearchInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
isSearching={isSearching}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Virtualized table */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={cn(
|
||||
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
|
||||
'transition-all duration-300 ease-out',
|
||||
isSearching && 'bg-surface-secondary/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
|
||||
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
|
||||
style={getColumnStyle(
|
||||
header.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
onClick={
|
||||
header.column.getCanSort()
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
scope="col"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{isLoading && skeletons}
|
||||
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<MemoizedTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
isSmallScreen={isSmallScreen}
|
||||
index={virtualRow.index}
|
||||
isSearching={isSearching}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!virtualRows.length && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4 text-center">
|
||||
No data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{(isFetchingNextPage || hasNextPage) && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
hasNextPage && <div className="h-6" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
packages/client/src/components/DataTable/DataTable.hooks.ts
Normal file
135
packages/client/src/components/DataTable/DataTable.hooks.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { TableColumn } from './DataTable.types';
|
||||
|
||||
export function useDebounced<T>(value: T, delay: number) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export const useOptimizedRowSelection = (initialSelection: Record<string, boolean> = {}) => {
|
||||
const [selection, setSelection] = useState(initialSelection);
|
||||
return [selection, setSelection] as const;
|
||||
};
|
||||
|
||||
export const useColumnStyles = <TData, TValue>(
|
||||
columns: TableColumn<TData, TValue>[],
|
||||
isSmallScreen: boolean,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
) => {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
setContainerWidth(container.clientWidth);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(container);
|
||||
updateWidth();
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [containerRef]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (containerWidth === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {};
|
||||
let totalFixedWidth = 0;
|
||||
const flexibleColumns: (TableColumn<TData, TValue> & { priority: number })[] = [];
|
||||
|
||||
columns.forEach((column) => {
|
||||
const key = String(column.id ?? column.accessorKey ?? '');
|
||||
const size = isSmallScreen ? column.meta?.mobileSize : column.meta?.size;
|
||||
|
||||
if (size) {
|
||||
const width = parseInt(String(size), 10);
|
||||
totalFixedWidth += width;
|
||||
styles[key] = {
|
||||
width: size,
|
||||
minWidth: column.meta?.minWidth || size,
|
||||
};
|
||||
} else {
|
||||
flexibleColumns.push({ ...column, priority: column.meta?.priority ?? 1 });
|
||||
}
|
||||
});
|
||||
|
||||
const availableWidth = containerWidth - totalFixedWidth;
|
||||
const totalPriority = flexibleColumns.reduce((sum, col) => sum + col.priority, 0);
|
||||
|
||||
if (availableWidth > 0 && totalPriority > 0) {
|
||||
flexibleColumns.forEach((column) => {
|
||||
const key = String(column.id ?? column.accessorKey ?? '');
|
||||
const proportion = column.priority / totalPriority;
|
||||
const width = Math.max(Math.floor(availableWidth * proportion), 80); // min width of 80px
|
||||
styles[key] = {
|
||||
width: `${width}px`,
|
||||
minWidth: column.meta?.minWidth ?? `${isSmallScreen ? 60 : 80}px`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return styles;
|
||||
}, [columns, containerWidth, isSmallScreen]);
|
||||
};
|
||||
|
||||
export const useDynamicColumnWidths = useColumnStyles;
|
||||
|
||||
export const useKeyboardNavigation = (
|
||||
tableRef: React.RefObject<HTMLDivElement>,
|
||||
rowCount: number,
|
||||
onRowSelect?: (index: number) => void,
|
||||
) => {
|
||||
const [focusedRowIndex, setFocusedRowIndex] = useState<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!tableRef.current?.contains(event.target as Node)) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex((prev) => Math.min(prev + 1, rowCount - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex(0);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex(rowCount - 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (focusedRowIndex >= 0 && onRowSelect) {
|
||||
event.preventDefault();
|
||||
onRowSelect(focusedRowIndex);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setFocusedRowIndex(-1);
|
||||
(event.target as HTMLElement).blur();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [tableRef, rowCount, focusedRowIndex, onRowSelect]);
|
||||
|
||||
return { focusedRowIndex, setFocusedRowIndex };
|
||||
};
|
||||
609
packages/client/src/components/DataTable/DataTable.tsx
Normal file
609
packages/client/src/components/DataTable/DataTable.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide-react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type Table as TTable,
|
||||
} from '@tanstack/react-table';
|
||||
import type { DataTableProps, ProcessedDataRow } from './DataTable.types';
|
||||
import { SelectionCheckbox, MemoizedTableRow, SkeletonRows } from './DataTableComponents';
|
||||
import { Table, TableBody, TableHead, TableHeader, TableCell, TableRow } from '../Table';
|
||||
import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks';
|
||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { DataTableSearch } from './DataTableSearch';
|
||||
import { cn, logger } from '~/utils';
|
||||
import { Label } from '../Label';
|
||||
import { Spinner } from '~/svgs';
|
||||
|
||||
function DataTable<TData extends Record<string, unknown>, TValue>({
|
||||
columns,
|
||||
data,
|
||||
className = '',
|
||||
isLoading = false,
|
||||
isFetching = false,
|
||||
config,
|
||||
filterValue = '',
|
||||
onFilterChange,
|
||||
defaultSort = [],
|
||||
isFetchingNextPage = false,
|
||||
hasNextPage = false,
|
||||
fetchNextPage,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
customActionsRenderer,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollTimeoutRef = useRef<number | null>(null);
|
||||
const scrollRAFRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
selection: { enableRowSelection = true, showCheckboxes = true } = {},
|
||||
search: { enableSearch = true, debounce: debounceDelay = 300 } = {},
|
||||
skeleton: { count: skeletonCount = 10 } = {},
|
||||
virtualization: {
|
||||
overscan = 10,
|
||||
minRows = 50,
|
||||
rowHeight = 56,
|
||||
fastOverscanMultiplier = 4,
|
||||
} = {},
|
||||
} = config || {};
|
||||
|
||||
const virtualizationActive = data.length >= minRows;
|
||||
|
||||
// Dynamic overscan for fast scrolling - increases rendered rows during rapid scroll
|
||||
const [dynamicOverscan, setDynamicOverscan] = useState(overscan);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const lastScrollTimeRef = useRef(performance.now());
|
||||
const fastScrollTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDynamicOverscan(overscan);
|
||||
}, [overscan]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fastScrollTimeoutRef.current) {
|
||||
clearTimeout(fastScrollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
|
||||
const [searchTerm, setSearchTerm] = useState(filterValue);
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>(defaultSort);
|
||||
|
||||
const selectedCount = Object.keys(optimizedRowSelection).length;
|
||||
const isAllSelected = useMemo(
|
||||
() => data.length > 0 && selectedCount === data.length,
|
||||
[data.length, selectedCount],
|
||||
);
|
||||
const isIndeterminate = selectedCount > 0 && !isAllSelected;
|
||||
|
||||
const getRowId = useCallback(
|
||||
(row: TData, index?: number) => String(row.id ?? `row-${index ?? 0}`),
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedRows = useMemo(() => {
|
||||
if (Object.keys(optimizedRowSelection).length === 0) return [];
|
||||
|
||||
const dataMap = new Map(data.map((item, index) => [getRowId(item, index), item]));
|
||||
return Object.keys(optimizedRowSelection)
|
||||
.map((id) => dataMap.get(id))
|
||||
.filter(Boolean) as TData[];
|
||||
}, [optimizedRowSelection, data, getRowId]);
|
||||
|
||||
const cleanupTimers = useCallback(() => {
|
||||
if (scrollRAFRef.current) {
|
||||
cancelAnimationFrame(scrollRAFRef.current);
|
||||
scrollRAFRef.current = null;
|
||||
}
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
scrollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedTerm = useDebounced(searchTerm, debounceDelay);
|
||||
const finalSorting = sorting ?? internalSorting;
|
||||
|
||||
// Mobile column visibility: columns with desktopOnly meta are hidden via CSS on mobile
|
||||
// but remain in DOM for accessibility. CSS classes handle visual hiding.
|
||||
const calculatedVisibility = useMemo(() => {
|
||||
const newVisibility: VisibilityState = {};
|
||||
|
||||
columns.forEach((col) => {
|
||||
const meta = (col as { meta?: { desktopOnly?: boolean } }).meta;
|
||||
if (!meta?.desktopOnly) return;
|
||||
|
||||
const rawId =
|
||||
(col as { id?: string | number; accessorKey?: string | number }).id ??
|
||||
(col as { accessorKey?: string | number }).accessorKey;
|
||||
|
||||
if ((typeof rawId === 'string' || typeof rawId === 'number') && String(rawId).length > 0) {
|
||||
newVisibility[String(rawId)] = true;
|
||||
} else {
|
||||
logger.warn(
|
||||
'DataTable: A desktopOnly column is missing id/accessorKey; cannot control header visibility automatically.',
|
||||
col,
|
||||
);
|
||||
}
|
||||
});
|
||||
return newVisibility;
|
||||
}, [isSmallScreen, columns]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibility((prev) => ({ ...prev, ...calculatedVisibility }));
|
||||
}, [calculatedVisibility]);
|
||||
|
||||
// Warn about missing row IDs - only once per component lifecycle
|
||||
const hasWarnedAboutMissingIds = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0 && !hasWarnedAboutMissingIds.current) {
|
||||
const missing = data.filter((item) => item.id === null || item.id === undefined);
|
||||
if (missing.length > 0) {
|
||||
logger.warn(
|
||||
`DataTable Warning: ${missing.length} data rows are missing a unique "id" property. Using index as a fallback. This can lead to unexpected behavior with selection and sorting.`,
|
||||
{ missingCount: missing.length, sample: missing.slice(0, 3) },
|
||||
);
|
||||
hasWarnedAboutMissingIds.current = true;
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const tableColumns = useMemo((): ColumnDef<TData, TValue>[] => {
|
||||
if (!enableRowSelection || !showCheckboxes) {
|
||||
return columns.map((col) => col as unknown as ColumnDef<TData, TValue>);
|
||||
}
|
||||
|
||||
const selectColumn: ColumnDef<TData, TValue> = {
|
||||
id: 'select',
|
||||
enableResizing: false,
|
||||
header: () => {
|
||||
const extraCheckboxProps = (isIndeterminate ? { indeterminate: true } : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return (
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
aria-label={localize('com_ui_select_all')}
|
||||
>
|
||||
<SelectionCheckbox
|
||||
checked={isAllSelected}
|
||||
onChange={(value) => {
|
||||
if (isAllSelected || !value) {
|
||||
setOptimizedRowSelection({});
|
||||
} else {
|
||||
const allSelection = data.reduce<Record<string, boolean>>((acc, item, index) => {
|
||||
acc[getRowId(item, index)] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
setOptimizedRowSelection(allSelection);
|
||||
}
|
||||
}}
|
||||
ariaLabel={localize('com_ui_select_all')}
|
||||
{...extraCheckboxProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const rowDescription = row.original.name
|
||||
? `named ${row.original.name}`
|
||||
: `at position ${row.index + 1}`;
|
||||
return (
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={localize(`com_ui_select_row`, { 0: rowDescription })}
|
||||
>
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={(value) => row.toggleSelected(value)}
|
||||
ariaLabel={localize(`com_ui_select_row`, { 0: rowDescription })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: 'max-w-[20px] flex-1',
|
||||
},
|
||||
};
|
||||
|
||||
return [selectColumn, ...columns.map((col) => col as unknown as ColumnDef<TData, TValue>)];
|
||||
}, [
|
||||
columns,
|
||||
enableRowSelection,
|
||||
showCheckboxes,
|
||||
localize,
|
||||
data,
|
||||
getRowId,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
setOptimizedRowSelection,
|
||||
]);
|
||||
|
||||
const sizedColumns = tableColumns;
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns: sizedColumns,
|
||||
getRowId: getRowId,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableRowSelection,
|
||||
enableMultiRowSelection: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
state: {
|
||||
sorting: finalSorting,
|
||||
columnVisibility,
|
||||
rowSelection: optimizedRowSelection,
|
||||
},
|
||||
onSortingChange: onSortingChange ?? setInternalSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setOptimizedRowSelection,
|
||||
});
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
enabled: virtualizationActive,
|
||||
count: data.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
getItemKey: (index) => getRowId(data[index] as TData, index),
|
||||
estimateSize: useCallback(() => rowHeight, [rowHeight]),
|
||||
overscan: dynamicOverscan,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows[0]?.start ?? 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0) : 0;
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
|
||||
const showSkeletons = isLoading || (isFetching && !isFetchingNextPage);
|
||||
const shouldShowSearch = enableSearch && onFilterChange;
|
||||
|
||||
// Render table body based on loading state and virtualization
|
||||
let tableBodyContent: React.ReactNode;
|
||||
if (showSkeletons) {
|
||||
tableBodyContent = (
|
||||
<SkeletonRows
|
||||
count={skeletonCount}
|
||||
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
|
||||
/>
|
||||
);
|
||||
} else if (virtualizationActive) {
|
||||
tableBodyContent = (
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
<TableRow aria-hidden="true">
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
style={{ height: paddingTop, padding: 0, border: 0 }}
|
||||
/>
|
||||
</TableRow>
|
||||
)}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
return (
|
||||
<MemoizedTableRow
|
||||
key={virtualRow.key}
|
||||
row={row as unknown as Row<Record<string, unknown>>}
|
||||
virtualIndex={virtualRow.index}
|
||||
selected={row.getIsSelected()}
|
||||
style={{ height: rowHeight }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<TableRow aria-hidden="true">
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
style={{ height: paddingBottom, padding: 0, border: 0 }}
|
||||
/>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
tableBodyContent = rows.map((row) => (
|
||||
<MemoizedTableRow
|
||||
key={getRowId(row.original as TData, row.index)}
|
||||
row={row as unknown as Row<Record<string, unknown>>}
|
||||
virtualIndex={row.index}
|
||||
selected={row.getIsSelected()}
|
||||
style={{ height: rowHeight }}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(filterValue);
|
||||
}, [filterValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTerm !== filterValue && onFilterChange) {
|
||||
onFilterChange(debouncedTerm);
|
||||
setOptimizedRowSelection({});
|
||||
}
|
||||
}, [debouncedTerm, filterValue, onFilterChange, setOptimizedRowSelection]);
|
||||
|
||||
// Recalculate virtual range when data or state changes
|
||||
useEffect(() => {
|
||||
if (!virtualizationActive) return;
|
||||
rowVirtualizer.calculateRange();
|
||||
}, [data.length, finalSorting, columnVisibility, virtualizationActive, rowVirtualizer]);
|
||||
|
||||
// Recalculate when container is resized
|
||||
useEffect(() => {
|
||||
if (!virtualizationActive) return;
|
||||
const container = tableContainerRef.current;
|
||||
if (!container) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
rowVirtualizer.calculateRange();
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, [virtualizationActive, rowVirtualizer]);
|
||||
|
||||
const handleScroll = useMemo(() => {
|
||||
let rafId: number | null = null;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
return () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const container = tableContainerRef.current;
|
||||
if (container) {
|
||||
const now = performance.now();
|
||||
const delta = Math.abs(container.scrollTop - lastScrollTopRef.current);
|
||||
const dt = now - lastScrollTimeRef.current;
|
||||
if (dt > 0) {
|
||||
const velocity = delta / dt;
|
||||
// Increase overscan during fast scrolling for smoother experience
|
||||
if (velocity > 2 && virtualizationActive && dynamicOverscan === overscan) {
|
||||
if (fastScrollTimeoutRef.current) {
|
||||
window.clearTimeout(fastScrollTimeoutRef.current);
|
||||
}
|
||||
setDynamicOverscan(Math.min(overscan * fastOverscanMultiplier, overscan * 8));
|
||||
fastScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
setDynamicOverscan((current) => (current !== overscan ? overscan : current));
|
||||
}, 160);
|
||||
}
|
||||
}
|
||||
lastScrollTopRef.current = container.scrollTop;
|
||||
lastScrollTimeRef.current = now;
|
||||
}
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
// Trigger infinite scroll pagination
|
||||
timeoutId = window.setTimeout(() => {
|
||||
const loaderContainer = tableContainerRef.current;
|
||||
if (!loaderContainer || !fetchNextPage || !hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = loaderContainer;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 200) {
|
||||
fetchNextPage().finally();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
}, [
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
overscan,
|
||||
fastOverscanMultiplier,
|
||||
virtualizationActive,
|
||||
dynamicOverscan,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = tableContainerRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', handleScroll);
|
||||
cleanupTimers();
|
||||
};
|
||||
}, [handleScroll, cleanupTimers]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full flex-col overflow-hidden rounded-lg border border-border-light bg-background',
|
||||
'h-[calc(100vh-8rem)] max-h-[80vh]',
|
||||
className,
|
||||
)}
|
||||
role="region"
|
||||
aria-label={localize('com_ui_data_table')}
|
||||
>
|
||||
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light md:gap-3">
|
||||
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
|
||||
{customActionsRenderer &&
|
||||
customActionsRenderer({
|
||||
selectedCount,
|
||||
selectedRows,
|
||||
table: table as unknown as TTable<ProcessedDataRow<TData>>,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className="overflow-anchor-none relative min-h-0 flex-1 overflow-auto will-change-scroll"
|
||||
style={
|
||||
{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
overscrollBehavior: 'contain',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
role="region"
|
||||
aria-label={localize('com_ui_data_table_scroll_area')}
|
||||
aria-describedby={showSkeletons ? 'loading-status' : undefined}
|
||||
>
|
||||
<Table
|
||||
role="table"
|
||||
aria-label={localize('com_ui_data_table')}
|
||||
aria-rowcount={data.length}
|
||||
className="table-auto"
|
||||
unwrapped={true}
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isDesktopOnly =
|
||||
(header.column.columnDef.meta as { desktopOnly?: boolean } | undefined)
|
||||
?.desktopOnly ?? false;
|
||||
|
||||
if (!header.column.getIsVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelectHeader = header.id === 'select';
|
||||
const meta = header.column.columnDef.meta as { className?: string } | undefined;
|
||||
const canSort = header.column.getCanSort();
|
||||
|
||||
let sortAriaLabel: string | undefined;
|
||||
if (canSort) {
|
||||
const sortState = header.column.getIsSorted();
|
||||
let sortStateLabel = 'sortable';
|
||||
if (sortState === 'asc') {
|
||||
sortStateLabel = 'ascending';
|
||||
} else if (sortState === 'desc') {
|
||||
sortStateLabel = 'descending';
|
||||
}
|
||||
|
||||
const headerLabel =
|
||||
typeof header.column.columnDef.header === 'string'
|
||||
? header.column.columnDef.header
|
||||
: header.column.id;
|
||||
|
||||
sortAriaLabel = `${headerLabel ?? ''} column, ${sortStateLabel}`;
|
||||
}
|
||||
|
||||
const handleSortingKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (canSort && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
header.column.toggleSorting();
|
||||
}
|
||||
};
|
||||
|
||||
const metaWidth = (header.column.columnDef.meta as { width?: number } | undefined)
|
||||
?.width;
|
||||
const widthStyle = isSelectHeader
|
||||
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
|
||||
: metaWidth && metaWidth >= 1 && metaWidth <= 100
|
||||
? {
|
||||
width: `${metaWidth}%`,
|
||||
maxWidth: `${metaWidth}%`,
|
||||
minWidth: `${metaWidth}%`,
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'border-b border-border-light px-2 py-2 md:px-3 md:py-2',
|
||||
isSelectHeader && 'px-0 text-center',
|
||||
canSort && 'cursor-pointer hover:bg-surface-tertiary',
|
||||
meta?.className,
|
||||
header.column.getIsResizing() && 'bg-surface-tertiary/60',
|
||||
isDesktopOnly && 'hidden md:table-cell',
|
||||
)}
|
||||
style={widthStyle}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
onKeyDown={handleSortingKeyDown}
|
||||
role={canSort ? 'button' : undefined}
|
||||
tabIndex={canSort ? 0 : undefined}
|
||||
aria-label={sortAriaLabel}
|
||||
aria-sort={
|
||||
header.column.getIsSorted() === 'asc'
|
||||
? 'ascending'
|
||||
: header.column.getIsSorted() === 'desc'
|
||||
? 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isSelectHeader ? (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
) : (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{canSort && (
|
||||
<span className="text-text-primary" aria-hidden="true">
|
||||
{{
|
||||
asc: <ArrowUp className="size-4 text-text-primary" />,
|
||||
desc: <ArrowDown className="size-4 text-text-primary" />,
|
||||
}[header.column.getIsSorted() as string] ?? (
|
||||
<ArrowDownUp className="size-4 text-text-primary" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{tableBodyContent}
|
||||
{isFetchingNextPage && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
className="p-4 text-center"
|
||||
id="loading-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">{localize('com_ui_loading_more_data')}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!isLoading && !showSkeletons && rows.length === 0 && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-12"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Label className="text-center text-text-secondary">
|
||||
{searchTerm ? localize('com_ui_no_search_results') : localize('com_ui_no_data')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
116
packages/client/src/components/DataTable/DataTable.types.ts
Normal file
116
packages/client/src/components/DataTable/DataTable.types.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { ColumnDef, SortingState, Table } from '@tanstack/react-table';
|
||||
import type React from 'react';
|
||||
|
||||
export type ProcessedDataRow<TData> = TData & { _id: string; _index: number };
|
||||
|
||||
export type TableColumnDef<TData, TValue> = ColumnDef<ProcessedDataRow<TData>, TValue>;
|
||||
|
||||
export type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
accessorKey?: string | number;
|
||||
meta?: {
|
||||
/** Column width as a percentage (1-100). Used for proportional column sizing. */
|
||||
width?: number;
|
||||
/** Additional CSS classes to apply to the column cells and header. */
|
||||
className?: string;
|
||||
/**
|
||||
* When true, this column will be hidden on mobile devices (viewport < 768px).
|
||||
* This is useful for hiding less critical information on smaller screens.
|
||||
*
|
||||
* **Usage Example:**
|
||||
* ```typescript
|
||||
* {
|
||||
* accessorKey: 'createdAt',
|
||||
* header: 'Date Created',
|
||||
* cell: ({ row }) => formatDate(row.original.createdAt),
|
||||
* meta: {
|
||||
* desktopOnly: true, // Hide this column on mobile
|
||||
* width: 20,
|
||||
* className: 'min-w-[6rem]'
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The column will be completely hidden including:
|
||||
* - Header cell
|
||||
* - Data cells
|
||||
* - Skeleton loading cells
|
||||
*/
|
||||
desktopOnly?: boolean;
|
||||
/**
|
||||
* When true, this column's cells will use `<th scope="row">` instead of `<td>`.
|
||||
* This is important for accessibility as it marks the cell as a row header,
|
||||
* providing context for screen readers about what each row represents.
|
||||
*
|
||||
* Typically the first column (e.g., name, title) should be marked as a row header.
|
||||
*
|
||||
* **Usage Example:**
|
||||
* ```typescript
|
||||
* {
|
||||
* accessorKey: 'title',
|
||||
* header: 'Conversation Name',
|
||||
* cell: ({ row }) => row.original.title,
|
||||
* meta: {
|
||||
* isRowHeader: true // Mark this column as row headers
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
isRowHeader?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export interface DataTableConfig {
|
||||
selection?: {
|
||||
enableRowSelection?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
};
|
||||
search?: {
|
||||
enableSearch?: boolean;
|
||||
debounce?: number;
|
||||
filterColumn?: string;
|
||||
};
|
||||
skeleton?: {
|
||||
count?: number;
|
||||
};
|
||||
virtualization?: {
|
||||
overscan?: number;
|
||||
minRows?: number;
|
||||
rowHeight?: number;
|
||||
fastOverscanMultiplier?: number;
|
||||
};
|
||||
pinning?: {
|
||||
enableColumnPinning?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DataTableProps<TData extends Record<string, unknown>, TValue> {
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
data: TData[];
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
config?: DataTableConfig;
|
||||
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||
filterValue?: string;
|
||||
onFilterChange?: (value: string) => void;
|
||||
defaultSort?: SortingState;
|
||||
isFetchingNextPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage?: () => Promise<unknown>;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void;
|
||||
conversationIndex?: number;
|
||||
customActionsRenderer?: (params: {
|
||||
selectedCount: number;
|
||||
selectedRows: TData[];
|
||||
table: Table<ProcessedDataRow<TData>>;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DataTableSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
168
packages/client/src/components/DataTable/DataTableComponents.tsx
Normal file
168
packages/client/src/components/DataTable/DataTableComponents.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { memo, forwardRef } from 'react';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import type { TableColumn } from './DataTable.types';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import { TableCell, TableRow, TableRowHeader } from '../Table';
|
||||
import { Checkbox } from '../Checkbox';
|
||||
import { Skeleton } from '../Skeleton';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export const SelectionCheckbox = memo(
|
||||
({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
ariaLabel: string;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(!checked);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex h-full w-8 items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(!checked);
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||
|
||||
interface TableRowComponentProps<TData extends Record<string, unknown>> {
|
||||
row: Row<TData>;
|
||||
virtualIndex?: number;
|
||||
style?: React.CSSProperties;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// ...existing code...
|
||||
const TableRowComponent = <TData extends Record<string, unknown>>(
|
||||
{ row, virtualIndex, style, selected }: TableRowComponentProps<TData>,
|
||||
ref: React.Ref<HTMLTableRowElement>,
|
||||
) => {
|
||||
// Check if we're on mobile - use window.innerWidth for component-level check
|
||||
const isSmallScreen = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={ref}
|
||||
data-state={selected ? 'selected' : undefined}
|
||||
data-index={virtualIndex}
|
||||
className="border-none hover:bg-surface-secondary"
|
||||
style={style}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta as
|
||||
| { className?: string; desktopOnly?: boolean; width?: number; isRowHeader?: boolean }
|
||||
| undefined;
|
||||
const isDesktopOnly = meta?.desktopOnly;
|
||||
const isRowHeader = meta?.isRowHeader;
|
||||
const percent = meta?.width;
|
||||
const widthStyle =
|
||||
cell.column.id === 'select'
|
||||
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
|
||||
: percent
|
||||
? {
|
||||
width: `${percent}%`,
|
||||
maxWidth: `${percent}%`,
|
||||
minWidth: `${percent}%`, // Don't shrink on mobile
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const CellComponent = isRowHeader ? TableRowHeader : TableCell;
|
||||
|
||||
// For desktop-only columns on mobile, keep them in DOM but visually hidden
|
||||
// This ensures screen readers can still access the content
|
||||
const cellProps =
|
||||
isDesktopOnly && isSmallScreen
|
||||
? { 'aria-hidden': false as const } // Keep accessible to screen readers
|
||||
: {};
|
||||
|
||||
return (
|
||||
<CellComponent
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'max-w-0 truncate px-2 py-2 md:px-3 md:py-3',
|
||||
cell.column.id === 'select' && 'w-8 p-1',
|
||||
meta?.className,
|
||||
isDesktopOnly && 'hidden md:table-cell',
|
||||
)}
|
||||
style={widthStyle}
|
||||
{...cellProps}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</CellComponent>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
// ...existing code...
|
||||
|
||||
type ForwardTableRowComponentType = <TData extends Record<string, unknown>>(
|
||||
props: TableRowComponentProps<TData> & React.RefAttributes<HTMLTableRowElement>,
|
||||
) => JSX.Element;
|
||||
|
||||
const ForwardTableRowComponent = forwardRef(TableRowComponent) as ForwardTableRowComponentType;
|
||||
|
||||
interface GenericRowProps {
|
||||
row: Row<Record<string, unknown>>;
|
||||
virtualIndex?: number;
|
||||
style?: React.CSSProperties;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export const MemoizedTableRow = memo(
|
||||
ForwardTableRowComponent as (props: GenericRowProps) => JSX.Element,
|
||||
(prev: GenericRowProps, next: GenericRowProps) =>
|
||||
prev.row.original === next.row.original && prev.selected === next.selected,
|
||||
);
|
||||
|
||||
export const SkeletonRows = memo(
|
||||
<TData extends Record<string, unknown>, TValue>({
|
||||
count = 10,
|
||||
columns,
|
||||
}: {
|
||||
count?: number;
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
}) => (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<TableRow key={`skeleton-${index}`} className="h-[56px] border-b border-border-light">
|
||||
{columns.map((column) => {
|
||||
const columnKey = String(
|
||||
column.id ?? ('accessorKey' in column && column.accessorKey) ?? '',
|
||||
);
|
||||
const meta = column.meta as { className?: string; desktopOnly?: boolean } | undefined;
|
||||
return (
|
||||
<TableCell
|
||||
key={columnKey}
|
||||
className={cn(
|
||||
'px-2 py-2 md:px-3',
|
||||
meta?.className,
|
||||
meta?.desktopOnly && 'hidden md:table-cell',
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
);
|
||||
|
||||
SkeletonRows.displayName = 'SkeletonRows';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Component, ErrorInfo, ReactNode, createRef } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Button } from '../Button';
|
||||
import { logger } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Error boundary specifically for DataTable component.
|
||||
* Catches JavaScript errors in the table rendering and provides a fallback UI.
|
||||
* Handles errors from virtualizer, cell renderers, fetch operations, and child components.
|
||||
*/
|
||||
interface DataTableErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface DataTableErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
onError?: (error: Error) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
interface DataTableErrorBoundaryInnerProps extends DataTableErrorBoundaryProps {
|
||||
localize: ReturnType<typeof useLocalize>;
|
||||
}
|
||||
|
||||
class DataTableErrorBoundaryInner extends Component<
|
||||
DataTableErrorBoundaryInnerProps,
|
||||
DataTableErrorBoundaryState
|
||||
> {
|
||||
private errorCardRef = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props: DataTableErrorBoundaryInnerProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): DataTableErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
logger.error('DataTable Error Boundary caught an error:', error, errorInfo);
|
||||
this.props.onError?.(error);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
_prevProps: DataTableErrorBoundaryInnerProps,
|
||||
prevState: DataTableErrorBoundaryState,
|
||||
) {
|
||||
if (!prevState.hasError && this.state.hasError && this.errorCardRef.current) {
|
||||
this.errorCardRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the error state and attempt to re-render the children.
|
||||
* This can be used to retry after a table error (e.g., network retry).
|
||||
*/
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
this.props.onReset?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center p-8">
|
||||
<div
|
||||
ref={this.errorCardRef}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-labelledby="datatable-error-title"
|
||||
aria-describedby="datatable-error-desc"
|
||||
tabIndex={-1}
|
||||
className="before:bg-surface-destructive/80 relative w-full max-w-md overflow-hidden rounded-lg border border-border-light bg-surface-primary-alt p-6 shadow-sm outline-none before:absolute before:left-0 before:top-0 before:h-full before:w-1 focus:ring-2 focus:ring-ring focus:ring-offset-2 dark:border-border-medium dark:bg-surface-secondary"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-surface-destructive" />
|
||||
<h3 id="datatable-error-title" className="text-sm font-medium text-text-primary">
|
||||
{this.props.localize('com_ui_table_error')}
|
||||
</h3>
|
||||
</div>
|
||||
<p id="datatable-error-desc" className="mt-2 text-sm text-text-secondary">
|
||||
{this.props.localize('com_ui_table_error_description')}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={this.handleReset}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-surface-hover dark:hover:bg-surface-active"
|
||||
aria-label="Retry loading table"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{this.props.localize('com_ui_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{import.meta.env.MODE === 'development' && this.state.error && (
|
||||
<details className="mt-4 max-w-md rounded-md bg-surface-secondary p-3 text-xs dark:bg-surface-tertiary">
|
||||
<summary className="cursor-pointer font-medium text-text-primary">
|
||||
{this.props.localize('com_ui_error_details')}
|
||||
</summary>
|
||||
<pre className="mt-2 whitespace-pre-wrap text-text-secondary">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function DataTableErrorBoundary(props: DataTableErrorBoundaryProps) {
|
||||
const localize = useLocalize();
|
||||
return <DataTableErrorBoundaryInner {...props} localize={localize} />;
|
||||
}
|
||||
|
||||
export default DataTableErrorBoundary;
|
||||
37
packages/client/src/components/DataTable/DataTableSearch.tsx
Normal file
37
packages/client/src/components/DataTable/DataTableSearch.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { memo } from 'react';
|
||||
import { startTransition } from 'react';
|
||||
import type { DataTableSearchProps } from './DataTable.types';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Input } from '../Input';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export const DataTableSearch = memo(
|
||||
({ value, onChange, placeholder, className, disabled = false }: DataTableSearchProps) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<label htmlFor="table-search" className="sr-only">
|
||||
{localize('com_ui_search_table')}
|
||||
</label>
|
||||
<Input
|
||||
id="table-search"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
startTransition(() => onChange(e.target.value));
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={localize('com_ui_search_table')}
|
||||
aria-describedby="search-description"
|
||||
placeholder={placeholder || localize('com_ui_search')}
|
||||
className={cn('h-10 rounded-b-none border-0 bg-surface-secondary md:h-12', className)}
|
||||
/>
|
||||
<span id="search-description" className="sr-only">
|
||||
{localize('com_ui_search_table_description')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DataTableSearch.displayName = 'DataTableSearch';
|
||||
3
packages/client/src/components/DataTable/index.ts
Normal file
3
packages/client/src/components/DataTable/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as DataTable } from './DataTable';
|
||||
export * from './DataTable.types';
|
||||
// Removed legacy DataTableSettings exports (store/context) as column resizing & dynamic sizing were deprecated.
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './DropdownMenu';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Button } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className = '',
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const getSortIcon = () => {
|
||||
const sortDirection = column.getIsSorted();
|
||||
if (sortDirection === 'desc') {
|
||||
return <ArrowDownIcon className="ml-2 h-4 w-4" />;
|
||||
}
|
||||
if (sortDirection === 'asc') {
|
||||
return <ArrowUpIcon className="ml-2 h-4 w-4" />;
|
||||
}
|
||||
return <CaretSortIcon className="ml-2 h-4 w-4" />;
|
||||
};
|
||||
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
aria-label={localize('com_ui_filter_by', { title })}
|
||||
>
|
||||
<span>{title}</span>
|
||||
{getSortIcon()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-[1001]">
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectArrow,
|
||||
@@ -61,6 +61,7 @@ export default function MultiSelect<T extends string>({
|
||||
renderItemContent,
|
||||
}: MultiSelectProps<T>) {
|
||||
const selectRef = useRef<HTMLButtonElement>(null);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const handleValueChange = (values: T[]) => {
|
||||
setSelectedValues(values);
|
||||
@@ -71,7 +72,12 @@ export default function MultiSelect<T extends string>({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<SelectProvider value={selectedValues} setValue={handleValueChange}>
|
||||
<SelectProvider
|
||||
value={selectedValues}
|
||||
setValue={handleValueChange}
|
||||
open={isPopoverOpen}
|
||||
setOpen={setIsPopoverOpen}
|
||||
>
|
||||
{label && (
|
||||
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
|
||||
{label}
|
||||
@@ -92,7 +98,12 @@ export default function MultiSelect<T extends string>({
|
||||
<span className="mr-auto hidden truncate md:block">
|
||||
{renderSelectedValues(selectedValues, placeholder)}
|
||||
</span>
|
||||
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />
|
||||
<SelectArrow
|
||||
className={cn(
|
||||
'ml-1 hidden stroke-1 text-base opacity-75 transition-transform duration-300 md:block',
|
||||
isPopoverOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</Select>
|
||||
<SelectPopover
|
||||
gutter={4}
|
||||
|
||||
@@ -81,7 +81,6 @@ const DialogContent = React.forwardRef<
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
|
||||
unwrapped?: boolean;
|
||||
}
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, TableProps>(
|
||||
({ className, unwrapped = false, ...props }, ref) => {
|
||||
const tableElement = (
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
if (unwrapped) {
|
||||
return tableElement;
|
||||
}
|
||||
|
||||
return <div className="relative w-full overflow-auto">{tableElement}</div>;
|
||||
},
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
@@ -79,6 +89,22 @@ const TableCell = React.forwardRef<
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableRowHeader = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
scope="row"
|
||||
className={cn(
|
||||
'p-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRowHeader.displayName = 'TableRowHeader';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
@@ -87,4 +113,14 @@ const TableCaption = React.forwardRef<
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableRowHeader,
|
||||
TableCaption,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ export * from './AlertDialog';
|
||||
export * from './Breadcrumb';
|
||||
export * from './Button';
|
||||
export * from './Checkbox';
|
||||
export * from './DataTableColumnHeader';
|
||||
export * from './Dialog';
|
||||
export * from './DropdownMenu';
|
||||
export * from './HoverCard';
|
||||
@@ -31,13 +30,13 @@ export * from './InputOTP';
|
||||
export * from './MultiSearch';
|
||||
export * from './Resizable';
|
||||
export * from './Select';
|
||||
export * from './DataTable/index';
|
||||
export { default as Radio } from './Radio';
|
||||
export { default as Badge } from './Badge';
|
||||
export { default as Avatar } from './Avatar';
|
||||
export { default as Combobox } from './Combobox';
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export { default as SplitText } from './SplitText';
|
||||
export { default as DataTable } from './DataTable';
|
||||
export { default as FormInput } from './FormInput';
|
||||
export { default as PixelCard } from './PixelCard';
|
||||
export { default as FileUpload } from './FileUpload';
|
||||
|
||||
@@ -3,5 +3,27 @@
|
||||
"com_ui_no_options": "No options available",
|
||||
"com_ui_delete_selected_items": "Delete selected items",
|
||||
"com_ui_filter_by": "Filter by {{title}}",
|
||||
"com_ui_cancel_dialog": "Cancel dialog"
|
||||
"com_ui_cancel_dialog": "Cancel dialog",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
"com_ui_select_all": "Select All",
|
||||
"com_ui_no_selection": "No selection",
|
||||
"com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.",
|
||||
"com_ui_delete_success": "Items deleted successfully",
|
||||
"com_ui_retry": "Retry",
|
||||
"com_ui_selected_count": "{count} selected",
|
||||
"com_ui_data_table": "Data Table",
|
||||
"com_ui_no_data": "No data",
|
||||
"com_ui_delete_selected": "Delete Selected",
|
||||
"com_ui_search_table": "Search table",
|
||||
"com_ui_search_table_description": "Type to filter results",
|
||||
"com_ui_search": "Search",
|
||||
"com_ui_data_table_scroll_area": "Scrollable data table area",
|
||||
"com_ui_select_row": "Select row {{0}}",
|
||||
"com_ui_loading_more_data": "Loading more data...",
|
||||
"com_ui_no_search_results": "No search results found",
|
||||
"com_ui_table_error": "Table Error",
|
||||
"com_ui_table_error_description": "Table failed to load. Please refresh or try again.",
|
||||
"com_ui_error_details": "Error Details (Dev)",
|
||||
"com_ui_enabled": "Enabled",
|
||||
"com_ui_disabled": "Disabled"
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './utils';
|
||||
export * from './theme';
|
||||
export { default as logger } from './logger';
|
||||
|
||||
49
packages/client/src/utils/logger.ts
Normal file
49
packages/client/src/utils/logger.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const isLoggerEnabled = process.env.VITE_ENABLE_LOGGER === 'true';
|
||||
const loggerFilter = process.env.VITE_LOGGER_FILTER || '';
|
||||
|
||||
type LogFunction = (...args: unknown[]) => void;
|
||||
|
||||
const createLogFunction = (
|
||||
consoleMethod: LogFunction,
|
||||
type?: 'log' | 'warn' | 'error' | 'info' | 'debug' | 'dir',
|
||||
): LogFunction => {
|
||||
return (...args: unknown[]) => {
|
||||
if (isDevelopment || isLoggerEnabled) {
|
||||
const tag = typeof args[0] === 'string' ? args[0] : '';
|
||||
if (shouldLog(tag)) {
|
||||
if (tag && typeof args[1] === 'string' && type === 'error') {
|
||||
consoleMethod(`[${tag}] ${args[1]}`, ...args.slice(2));
|
||||
} else if (tag && args.length > 1) {
|
||||
consoleMethod(`[${tag}]`, ...args.slice(1));
|
||||
} else {
|
||||
consoleMethod(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const logger = {
|
||||
log: createLogFunction(console.log, 'log'),
|
||||
dir: createLogFunction(console.dir, 'dir'),
|
||||
warn: createLogFunction(console.warn, 'warn'),
|
||||
info: createLogFunction(console.info, 'info'),
|
||||
error: createLogFunction(console.error, 'error'),
|
||||
debug: createLogFunction(console.debug, 'debug'),
|
||||
};
|
||||
|
||||
function shouldLog(tag: string): boolean {
|
||||
if (!loggerFilter) {
|
||||
return true;
|
||||
}
|
||||
/* If no tag is provided, always log */
|
||||
if (!tag) {
|
||||
return true;
|
||||
}
|
||||
return loggerFilter
|
||||
.split(',')
|
||||
.some((filter) => tag.toLowerCase().includes(filter.trim().toLowerCase()));
|
||||
}
|
||||
|
||||
export default logger;
|
||||
@@ -5,5 +5,8 @@ import type * as t from '~/types';
|
||||
* Creates or returns the AgentCategory model using the provided mongoose instance and schema
|
||||
*/
|
||||
export function createAgentCategoryModel(mongoose: typeof import('mongoose')) {
|
||||
return mongoose.models.AgentCategory || mongoose.model<t.IAgentCategory>('AgentCategory', agentCategorySchema);
|
||||
}
|
||||
return (
|
||||
mongoose.models.AgentCategory ||
|
||||
mongoose.model<t.IAgentCategory>('AgentCategory', agentCategorySchema)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user