mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🪹 feat: Shared Empty State for Side Panels (#15123)
* feat: shared empty state for side panels Bookmarks and Memories each hand-rolled the same empty state: the same bordered card, the same circular icon surface, the same title and caption sizes, written out twice. Schedules had none at all, so an account with no schedules got a bare list with nothing to explain what the panel is for. One EmptyState primitive in packages/client, taking an icon, an optional title and description, and an optional action. Bookmarks and Memories move onto it with no visual change and no copy change. Schedules gets a real empty state, and an error state with a Retry action, so a panel that failed to load offers a way out instead of looking empty. A description with no title takes the title's size rather than the caption's: where it is the only line, it IS the message. * fix: drop the create hint for roles without schedule create access The panel already hides its create button behind hasCreateAccess, but the empty state still told a USE-only viewer to create a schedule it offers no way to create. The invitation now renders only when the capability does. * fix: suppress the create hint when the quota already blocks creation A maxPerUser of 0 disables the create button on an empty list, so the empty state must not say to create one either; the hint now follows the same effective gate as the button.
This commit is contained in:
parent
fc2b8584c4
commit
dd85c6d6d0
9 changed files with 196 additions and 48 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { Bookmark } from 'lucide-react';
|
||||
import { EmptyState } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface BookmarkEmptyStateProps {
|
||||
|
|
@ -8,23 +9,15 @@ interface BookmarkEmptyStateProps {
|
|||
export default function BookmarkEmptyState({ isFiltered = false }: BookmarkEmptyStateProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
if (isFiltered) {
|
||||
return <EmptyState icon={Bookmark} description={localize('com_ui_no_bookmarks_match')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center">
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
|
||||
<Bookmark className="size-5 text-text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
{isFiltered ? (
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_no_bookmarks_match')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_no_bookmarks_title')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">
|
||||
{localize('com_ui_add_first_bookmark')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Bookmark}
|
||||
title={localize('com_ui_no_bookmarks_title')}
|
||||
description={localize('com_ui_add_first_bookmark')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Brain } from 'lucide-react';
|
||||
import { EmptyState } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface MemoryEmptyStateProps {
|
||||
|
|
@ -8,21 +9,15 @@ interface MemoryEmptyStateProps {
|
|||
export default function MemoryEmptyState({ isFiltered = false }: MemoryEmptyStateProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
if (isFiltered) {
|
||||
return <EmptyState icon={Brain} description={localize('com_ui_no_memories_match')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center">
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
|
||||
<Brain className="size-5 text-text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
{isFiltered ? (
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_no_memories_match')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_no_memories_title')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">{localize('com_ui_no_memories')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Brain}
|
||||
title={localize('com_ui_no_memories_title')}
|
||||
description={localize('com_ui_no_memories')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { Button, EmptyState } from '@librechat/client';
|
||||
import { CalendarClock, TriangleAlert } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface ScheduleEmptyStateProps {
|
||||
canCreate?: boolean;
|
||||
isError?: boolean;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export default function ScheduleEmptyState({
|
||||
canCreate = false,
|
||||
isError = false,
|
||||
onRetry,
|
||||
}: ScheduleEmptyStateProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={TriangleAlert}
|
||||
title={localize('com_ui_schedules_error')}
|
||||
action={
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
{localize('com_ui_retry')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
icon={CalendarClock}
|
||||
title={localize('com_ui_no_schedules_title')}
|
||||
// Only invite creation where the panel actually offers it: a USE-only role
|
||||
// has no create button, so telling it to create one is a dead end.
|
||||
description={canCreate ? localize('com_ui_no_schedules') : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
|||
import { PanelContent, PanelFooter } from '~/components/ui';
|
||||
import { useChatProjectNames } from './useScheduleProjects';
|
||||
import ScheduleCardSkeleton from './ScheduleCardSkeleton';
|
||||
import ScheduleEmptyState from './ScheduleEmptyState';
|
||||
import { useSchedulesQuery } from '~/data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import ScheduleDialog from './ScheduleDialog';
|
||||
|
|
@ -32,22 +33,9 @@ export default function SchedulePanel() {
|
|||
let panelContent: ReactNode;
|
||||
|
||||
if (isError) {
|
||||
panelContent = (
|
||||
<div className="flex min-h-40 flex-col items-center justify-center gap-2 p-4">
|
||||
<p className="text-center text-sm text-text-secondary">
|
||||
{localize('com_ui_schedules_error')}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{localize('com_ui_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
panelContent = <ScheduleEmptyState isError onRetry={() => refetch()} />;
|
||||
} else if (schedules.length === 0) {
|
||||
panelContent = (
|
||||
<div className="rounded-lg border border-dashed border-border-light p-4 text-center">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_schedules_empty')}</p>
|
||||
</div>
|
||||
);
|
||||
panelContent = <ScheduleEmptyState canCreate={hasCreateAccess && !atLimit} />;
|
||||
} else {
|
||||
panelContent = (
|
||||
<div className="space-y-2" role="list" aria-label={localize('com_ui_schedules')}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ScheduleEmptyState from '../ScheduleEmptyState';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en-US' } }),
|
||||
}));
|
||||
|
||||
describe('ScheduleEmptyState', () => {
|
||||
it('shows the title and the create hint for a role that can create', () => {
|
||||
render(<ScheduleEmptyState canCreate />);
|
||||
|
||||
expect(screen.getByText('com_ui_no_schedules_title')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_no_schedules')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the create hint for a role the panel offers no create button to', () => {
|
||||
render(<ScheduleEmptyState />);
|
||||
|
||||
expect(screen.getByText('com_ui_no_schedules_title')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_no_schedules')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('swaps to the error message and retries on request', async () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<ScheduleEmptyState isError onRetry={onRetry} />);
|
||||
|
||||
expect(screen.getByText('com_ui_schedules_error')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_no_schedules_title')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1640,6 +1640,8 @@
|
|||
"com_ui_no_prompts_title": "No prompts yet",
|
||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
"com_ui_no_schedules": "Create one to have an agent run a prompt on a schedule",
|
||||
"com_ui_no_schedules_title": "No scheduled chats yet",
|
||||
"com_ui_no_search_results": "No results match your search",
|
||||
"com_ui_no_selection": "No selection",
|
||||
"com_ui_no_skills_found": "No skills found",
|
||||
|
|
@ -1925,7 +1927,6 @@
|
|||
"com_ui_schedule_weekly": "Weekly",
|
||||
"com_ui_schedules": "Scheduled chats",
|
||||
"com_ui_schedules_error": "Couldn't load your scheduled chats",
|
||||
"com_ui_schedules_empty": "No scheduled chats yet",
|
||||
"com_ui_schedules_used": "{{used}} of {{max}} schedules used",
|
||||
"com_ui_schema": "Schema",
|
||||
"com_ui_scope": "Scope",
|
||||
|
|
|
|||
39
packages/client/src/components/EmptyState.spec.tsx
Normal file
39
packages/client/src/components/EmptyState.spec.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Brain } from 'lucide-react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import EmptyState from './EmptyState';
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders a title, a description and a decorative icon', () => {
|
||||
const { container } = render(
|
||||
<EmptyState icon={Brain} title="No memories yet" description="Add one to get started" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No memories yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add one to get started')).toBeInTheDocument();
|
||||
// The icon carries the meaning already in the text, so it must not be announced.
|
||||
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it("gives a description the title's size when it stands alone", () => {
|
||||
// The "nothing matched your filter" shape has no title, so its one line must not
|
||||
// render as a caption underneath nothing.
|
||||
render(<EmptyState icon={Brain} description="No results match your filter" />);
|
||||
|
||||
expect(screen.getByText('No results match your filter')).toHaveClass('text-sm');
|
||||
expect(screen.getByText('No results match your filter')).not.toHaveClass('text-xs');
|
||||
});
|
||||
|
||||
it('renders an action when one is given, and nothing when it is not', () => {
|
||||
const { rerender } = render(<EmptyState icon={Brain} title="Could not load" />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<EmptyState
|
||||
icon={Brain}
|
||||
title="Could not load"
|
||||
action={<button type="button">Retry</button>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
50
packages/client/src/components/EmptyState.tsx
Normal file
50
packages/client/src/components/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Decorative, rendered inside the circular surface at a fixed size. */
|
||||
icon: LucideIcon;
|
||||
/** Omitted for the "nothing matched your filter" shape, which is a line on its own. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** A single call to action, e.g. a retry button. */
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel empty state: a bordered card with a circular icon, a title and a line of
|
||||
* explanation. Owned here because bookmarks, memories and schedules each render the
|
||||
* same card, and three copies of one appearance means a theme or spacing change has
|
||||
* to be made three times and will eventually be made twice.
|
||||
*/
|
||||
export default function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
|
||||
<Icon className="size-5 text-text-secondary" aria-hidden={true} />
|
||||
</div>
|
||||
{title != null && <p className="text-sm font-medium text-text-primary">{title}</p>}
|
||||
{description != null && (
|
||||
// Without a title the description IS the message, so it carries the title's
|
||||
// size rather than reading as a caption under nothing.
|
||||
<p className={cn(title == null ? 'text-sm' : 'mt-0.5 text-xs', 'text-text-secondary')}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action != null && <div className="mt-3">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -58,6 +58,8 @@ export { default as CheckboxButton } from './CheckboxButton';
|
|||
export { default as DialogTemplate } from './DialogTemplate';
|
||||
export { default as SelectDropDown } from './SelectDropDown';
|
||||
export { default as ControlCombobox } from './ControlCombobox';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export { default as TimePicker, MinutePicker, TimeColumn } from './TimePicker';
|
||||
export type {
|
||||
TimePickerProps,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue