mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-15 23:43:06 +03:00
🚪 fix: Align Mobile Sidebar Toggle Gating with JS Breakpoint Across Views (#13654)
* 🚪 fix: Align Mobile Sidebar Toggle Gating with JS Breakpoint Across Views * 🚪 fix: Sidebar Toggle on Read-Only Prompt Details View
This commit is contained in:
@@ -218,17 +218,17 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
{/* Sticky wrapper for search bar and categories */}
|
||||
<div className="sticky top-0 z-10 mt-4 bg-presentation pb-4 md:mt-0">
|
||||
<div className="container mx-auto max-w-4xl px-4">
|
||||
<div className="mx-auto mb-3 flex max-w-2xl items-center justify-between gap-2 md:hidden">
|
||||
<OpenSidebar />
|
||||
<MarketplaceAdminSettings compact />
|
||||
</div>
|
||||
{isSmallScreen ? (
|
||||
<div className="mx-auto mb-3 flex max-w-2xl items-center justify-between gap-2">
|
||||
<OpenSidebar />
|
||||
<MarketplaceAdminSettings compact />
|
||||
</div>
|
||||
) : null}
|
||||
{/* Search bar */}
|
||||
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
|
||||
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||||
{/* TODO: Remove this once we have a better way to handle admin settings */}
|
||||
<div className="hidden md:block">
|
||||
<MarketplaceAdminSettings />
|
||||
</div>
|
||||
{!isSmallScreen && <MarketplaceAdminSettings />}
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
|
||||
@@ -45,7 +45,7 @@ function Header() {
|
||||
<div className="via-presentation/70 md:from-presentation/80 md:via-presentation/50 2xl:from-presentation/0 absolute top-0 z-10 flex h-[52px] w-full items-center justify-between bg-gradient-to-b from-presentation to-transparent p-2 font-semibold text-text-primary 2xl:via-transparent">
|
||||
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
|
||||
<div className="mx-1 flex items-center">
|
||||
<OpenSidebar className="md:hidden" />
|
||||
{isSmallScreen ? <OpenSidebar /> : null}
|
||||
{!(navVisible && isSmallScreen) && (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, TextareaAutosize, Input } from '@librechat/client';
|
||||
import { useForm, Controller, FormProvider } from 'react-hook-form';
|
||||
import { Button, TextareaAutosize, Input, useMediaQuery } from '@librechat/client';
|
||||
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
|
||||
import CategorySelector from '../fields/CategorySelector';
|
||||
import VariablesDropdown from '../editor/VariablesDropdown';
|
||||
import CategorySelector from '../fields/CategorySelector';
|
||||
import PromptVariables from '../display/PromptVariables';
|
||||
import Description from '../fields/Description';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import Command from '../fields/Command';
|
||||
import { useCreatePrompt } from '~/data-provider';
|
||||
import Description from '../fields/Description';
|
||||
import Command from '../fields/Command';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type CreateFormValues = {
|
||||
@@ -42,6 +42,7 @@ const CreatePromptForm = ({
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const { hasAccess: hasUseAccess } = usePromptGroupsContext() ?? {};
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
@@ -114,10 +115,12 @@ const CreatePromptForm = ({
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="w-full px-4 py-2">
|
||||
<h1 className="sr-only">{localize('com_ui_create_prompt_page')}</h1>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 sm:hidden">
|
||||
<OpenSidebar />
|
||||
<CategorySelector />
|
||||
</div>
|
||||
{isSmallScreen ? (
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<OpenSidebar />
|
||||
<CategorySelector />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-1 flex flex-col items-center justify-between font-bold sm:text-xl md:mb-0 md:text-2xl">
|
||||
<div className="flex w-full flex-col items-center justify-between sm:flex-row">
|
||||
<Controller
|
||||
@@ -153,9 +156,11 @@ const CreatePromptForm = ({
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="hidden sm:block">
|
||||
<CategorySelector />
|
||||
</div>
|
||||
{!isSmallScreen && (
|
||||
<div>
|
||||
<CategorySelector />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Menu, Rocket, X } from 'lucide-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Menu, Rocket, X } from 'lucide-react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { Button, Skeleton, useToastContext } from '@librechat/client';
|
||||
import { Button, Skeleton, useToastContext, useMediaQuery } from '@librechat/client';
|
||||
import {
|
||||
Permissions,
|
||||
ResourceType,
|
||||
@@ -30,8 +30,8 @@ import DeletePrompt from '../dialogs/DeletePrompt';
|
||||
import NoPromptGroup from '../lists/NoPromptGroup';
|
||||
import PromptEditor from '../editor/PromptEditor';
|
||||
import SkeletonForm from '../utils/SkeletonForm';
|
||||
import Description from '../fields/Description';
|
||||
import SharePrompt from '../dialogs/SharePrompt';
|
||||
import Description from '../fields/Description';
|
||||
import PromptName from '../fields/PromptName';
|
||||
import { cn, findPromptGroup } from '~/utils';
|
||||
import { PromptsEditorMode } from '~/common';
|
||||
@@ -187,6 +187,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
|
||||
const promptId = promptIdProp || params.promptId || '';
|
||||
|
||||
const editorMode = useRecoilValue(store.promptsEditorMode);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [selectionIndex, setSelectionIndex] = useState<number>(0);
|
||||
|
||||
const prevIsEditingRef = useRef(false);
|
||||
@@ -437,7 +438,16 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
|
||||
}
|
||||
|
||||
if (fetchedPrompt || group) {
|
||||
return <PromptDetails group={fetchedPrompt || group} showActions={false} />;
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{isSmallScreen && (
|
||||
<div className="flex shrink-0 items-center px-4 pt-3">
|
||||
<OpenSidebar />
|
||||
</div>
|
||||
)}
|
||||
<PromptDetails group={fetchedPrompt || group} showActions={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,8 +472,8 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1 overflow-hidden px-4">
|
||||
{/* Mobile Actions Row */}
|
||||
{!isLoadingGroup && group && (
|
||||
<div className="mb-3 mt-2 flex items-center justify-between gap-2 sm:hidden">
|
||||
{!isLoadingGroup && group && isSmallScreen && (
|
||||
<div className="mb-3 mt-2 flex items-center justify-between gap-2">
|
||||
<OpenSidebar />
|
||||
<HeaderActions
|
||||
group={group}
|
||||
@@ -510,15 +520,17 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden shrink-0 sm:block">
|
||||
<HeaderActions
|
||||
group={group}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
selectedPromptId={selectedPromptId}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
</div>
|
||||
{!isSmallScreen && (
|
||||
<div className="shrink-0">
|
||||
<HeaderActions
|
||||
group={group}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
selectedPromptId={selectedPromptId}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Navigate, useNavigate, useParams, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import { Spinner, useMediaQuery } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useGetSkillByIdQuery } from '~/data-provider';
|
||||
import { useHasAccess, useAuthContext, useLocalize } from '~/hooks';
|
||||
import { Navigate, useNavigate, useParams, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import SkillFileViewer from '~/components/Skills/display/SkillFileViewer';
|
||||
import { CreateSkillForm, SkillForm } from '~/components/Skills/forms';
|
||||
import { useHasAccess, useAuthContext, useLocalize } from '~/hooks';
|
||||
import SkillDetail from '~/components/Skills/display/SkillDetail';
|
||||
import SkillState from '~/components/Skills/display/SkillState';
|
||||
import { CreateSkillForm, SkillForm } from '~/components/Skills/forms';
|
||||
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
|
||||
import { useGetSkillByIdQuery } from '~/data-provider';
|
||||
|
||||
/**
|
||||
* Skill detail / edit / create route content.
|
||||
@@ -58,11 +59,14 @@ export default function SkillsView() {
|
||||
// No skill selected — empty state
|
||||
if (!skillId) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-presentation">
|
||||
<SkillState
|
||||
title={localize('com_ui_skill_no_selection')}
|
||||
description={localize('com_ui_skill_no_selection_desc')}
|
||||
/>
|
||||
<div className="flex h-full w-full flex-col bg-presentation">
|
||||
<MobileSidebarToggle />
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<SkillState
|
||||
title={localize('com_ui_skill_no_selection')}
|
||||
description={localize('com_ui_skill_no_selection_desc')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +77,7 @@ export default function SkillsView() {
|
||||
function CreateView() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-presentation">
|
||||
<MobileSidebarToggle />
|
||||
<CreateSkillForm />
|
||||
</div>
|
||||
);
|
||||
@@ -90,6 +95,7 @@ function DetailView({ skillId }: { skillId: string }) {
|
||||
if (activeFile) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-presentation">
|
||||
<MobileSidebarToggle />
|
||||
<SkillFileViewer skillId={skillId} relativePath={activeFile} />
|
||||
</div>
|
||||
);
|
||||
@@ -106,6 +112,7 @@ function DetailView({ skillId }: { skillId: string }) {
|
||||
if (skillQuery.isError || !skillQuery.data) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-presentation">
|
||||
<MobileSidebarToggle />
|
||||
<SkillState
|
||||
variant="error"
|
||||
title={localize('com_ui_skill_not_found')}
|
||||
@@ -117,6 +124,7 @@ function DetailView({ skillId }: { skillId: string }) {
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-presentation">
|
||||
<MobileSidebarToggle />
|
||||
<SkillDetail
|
||||
skill={skillQuery.data}
|
||||
onEdit={() => navigate(`/skills/${skillId}/edit`)}
|
||||
@@ -130,7 +138,21 @@ function DetailView({ skillId }: { skillId: string }) {
|
||||
function EditView({ skillId }: { skillId: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-presentation">
|
||||
<MobileSidebarToggle />
|
||||
<SkillForm skillId={skillId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sidebar reopen affordance for small screens, where the drawer is the only navigation. */
|
||||
function MobileSidebarToggle() {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
if (!isSmallScreen) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex shrink-0 items-center px-4 pt-3">
|
||||
<OpenSidebar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import SkillsView from '../SkillsView';
|
||||
|
||||
const mockUseHasAccess = jest.fn((..._args: unknown[]) => true);
|
||||
const mockUseMediaQuery = jest.fn((_query: string) => false);
|
||||
|
||||
jest.mock(
|
||||
'librechat-data-provider',
|
||||
@@ -18,10 +19,16 @@ jest.mock(
|
||||
'@librechat/client',
|
||||
() => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
useMediaQuery: (query: string) => mockUseMediaQuery(query),
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
jest.mock('~/components/Chat/Menus/OpenSidebar', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="open-sidebar" />,
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useHasAccess: (...args: unknown[]) => mockUseHasAccess(...args),
|
||||
@@ -58,6 +65,8 @@ describe('SkillsView', () => {
|
||||
beforeEach(() => {
|
||||
mockUseHasAccess.mockReset();
|
||||
mockUseHasAccess.mockReturnValue(true);
|
||||
mockUseMediaQuery.mockReset();
|
||||
mockUseMediaQuery.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders the create skill form for /skills/new', () => {
|
||||
@@ -69,4 +78,25 @@ describe('SkillsView', () => {
|
||||
|
||||
expect(screen.getByTestId('create-skill-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the sidebar toggle on small screens', () => {
|
||||
mockUseMediaQuery.mockReturnValue(true);
|
||||
const router = createMemoryRouter([{ path: '/skills', element: <SkillsView /> }], {
|
||||
initialEntries: ['/skills'],
|
||||
});
|
||||
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
expect(screen.getByTestId('open-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the sidebar toggle on large screens', () => {
|
||||
const router = createMemoryRouter([{ path: '/skills', element: <SkillsView /> }], {
|
||||
initialEntries: ['/skills'],
|
||||
});
|
||||
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
expect(screen.queryByTestId('open-sidebar')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user