LibreChat/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx
Dustin Healy 0446d0e190
fix: Address Accessibility Issues (#10260)
* chore: add i18n localization comment for AlwaysMakeProd component

* feat: enhance accessibility by adding aria-label and aria-labelledby to Switch component

* feat: add aria-labels for accessibility in Agent and Assistant avatar buttons

* fix: add switch aria-labels for accessibility in various components

* feat: add aria-labels and localization keys for accessibility in DataTable, DataTableColumnHeader, and OGDialogTemplate components

* chore: refactor out nested ternary

* feat: add aria-label to DataTable filter button for My Files modal

* feat: add aria-labels for Buttons and localization strings

* feat: add aria-labels to Checkboxes in Agent Builder

* feat: enhance accessibility by adding aria-label and aria-labelledby to Checkbox component

* feat: add aria-label to FileSearchCheckbox in Agent Builder

* feat: add aria-label to Prompts text input area

* feat: enhance accessibility by adding aria-label and aria-labelledby to TextAreaAutosize component

* feat: remove improper role: "list" prop from List in Conversations.tsx to enhance accessibility and stop aria rules conflicting within react-virtualized component

* feat: enhance accessibility by allowing tab navigation and adding ring highlights for conversation title editing accept/reject buttons

* feat: add aria-label to Copy Link button in the conversation share modal

* feat: add title to QR code svg in conversation share modal to  describe the image content

* feat: enhance accessibility by making Agent Avatar upload keyboard navigable and round out highlight border on focus

* feat: enhance accessibility by adding aria attributes around alerting users with screen readers to invalid email address inputs in the Agent Builder

* feat: add aria-labels to buttons in Advanced panel of Agent Builder

* feat: enhance accessibility by making FileUpload and Clear All buttons in PresetItems keyboard navigable

* feat: enchance accessiblity by indexing view and delete button aria-labels in shared links management modal to their specific chat titles

* feat: add border highlighting on focus for AnimatedSearchInput

* feat: add category description to aria-labels for prompts in ListCard

* feat: add proper scoping to rows and columns in table headers

* feat: add localized aria-labelling to EditTextPart's TextAreaAutosize component and base dynamic paramters panel components and their supporting translation keys

* feat: add localized aria-labels and aria-labelledBy to Checkbox components without them

* feat: add localized aria-labeledBy for endpoint settings Sliders

* feat: add localized aria-labels for TextareaAutosize components

* chore: remove unused i18n string

* feat: add localized aria-label for BookmarkForm Checkbox

* fix: add stopPropagation onKeyDown for Preview and Edit menu items in prompts that was causing the prompts to inadvertently be sent when triggered with keyboard navigation when Auto-send Prompts was toggled on

* fix: switch TableCell to TableHead for title cells according to harvard issue #789

* fix: add more descriptive localization key for file filter button in DataTable

* chore: remove self-explanatory code comment from RenameForm

* fix: remove stray bg-yellow highlight that was left in during debugging

* fix: add aria-label to model configurator panel back button

* fix: undo incorrect hoist of tool name split for aria-label and span in MCPInput

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-27 19:46:43 -04:00

206 lines
6.7 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTemplate,
Button,
Label,
Input,
Spinner,
useToastContext,
} from '@librechat/client';
import type { TUserMemory } from 'librechat-data-provider';
import { useUpdateMemoryMutation, useMemoriesQuery } from '~/data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
interface MemoryEditDialogProps {
memory: TUserMemory | null;
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
}
export default function MemoryEditDialog({
memory,
open,
onOpenChange,
children,
triggerRef,
}: MemoryEditDialogProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: memData } = useMemoriesQuery();
const hasUpdateAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.UPDATE,
});
const { mutate: updateMemory, isLoading } = useUpdateMemoryMutation({
onMutate: () => {
onOpenChange(false);
setTimeout(() => {
triggerRef?.current?.focus();
}, 0);
},
onSuccess: () => {
showToast({
message: localize('com_ui_saved'),
status: 'success',
});
},
onError: (error: Error) => {
let errorMessage = localize('com_ui_error');
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as any;
if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
// Check for duplicate key error
if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) {
errorMessage = localize('com_ui_memory_key_exists');
}
// Check for key validation error (lowercase and underscores only)
else if (errorMessage.includes('lowercase letters and underscores')) {
errorMessage = localize('com_ui_memory_key_validation');
}
}
} else if (error.message) {
errorMessage = error.message;
}
showToast({
message: errorMessage,
status: 'error',
});
},
});
const [key, setKey] = useState('');
const [value, setValue] = useState('');
const [originalKey, setOriginalKey] = useState('');
useEffect(() => {
if (memory) {
setKey(memory.key);
setValue(memory.value);
setOriginalKey(memory.key);
}
}, [memory]);
const handleSave = () => {
if (!hasUpdateAccess || !memory) {
return;
}
if (!key.trim() || !value.trim()) {
showToast({
message: localize('com_ui_field_required'),
status: 'error',
});
return;
}
updateMemory({
key: key.trim(),
value: value.trim(),
...(originalKey !== key.trim() && { originalKey }),
});
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && e.ctrlKey && hasUpdateAccess) {
handleSave();
}
};
return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
{children}
<OGDialogTemplate
title={hasUpdateAccess ? localize('com_ui_edit_memory') : localize('com_ui_view_memory')}
showCloseButton={false}
className="w-11/12 md:max-w-lg"
main={
<div className="space-y-4">
{memory && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-text-secondary">
<div>
{localize('com_ui_date')}:{' '}
{new Date(memory.updated_at).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
{/* Token Information */}
{memory.tokenCount !== undefined && (
<div>
{memory.tokenCount.toLocaleString()}
{memData?.tokenLimit && ` / ${memData.tokenLimit.toLocaleString()}`}{' '}
{localize(memory.tokenCount === 1 ? 'com_ui_token' : 'com_ui_tokens')}
</div>
)}
</div>
{/* Overall Memory Usage */}
{memData?.tokenLimit && memData?.usagePercentage !== null && (
<div className="text-xs text-text-secondary">
{localize('com_ui_usage')}: {memData.usagePercentage}%{' '}
</div>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="memory-key" className="text-sm font-medium">
{localize('com_ui_key')}
</Label>
<Input
id="memory-key"
value={key}
onChange={(e) => hasUpdateAccess && setKey(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={localize('com_ui_enter_key')}
className="w-full"
disabled={!hasUpdateAccess}
/>
</div>
<div className="space-y-2">
<Label htmlFor="memory-value" className="text-sm font-medium">
{localize('com_ui_value')}
</Label>
<textarea
id="memory-value"
value={value}
onChange={(e) => hasUpdateAccess && setValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={localize('com_ui_enter_value')}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
rows={3}
disabled={!hasUpdateAccess}
/>
</div>
</div>
}
buttons={
hasUpdateAccess ? (
<Button
type="button"
variant="submit"
onClick={handleSave}
aria-label={localize('com_ui_save')}
disabled={isLoading || !key.trim() || !value.trim()}
className="text-white"
>
{isLoading ? <Spinner className="size-4" /> : localize('com_ui_save')}
</Button>
) : null
}
/>
</OGDialog>
);
}