Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c308c4be1 |
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Provider, useAtomValue } from 'jotai'
|
||||
import { detailSidebarModeAtom } from '@/app/components/detail-sidebar/state'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getInitialDetailSidebarMode: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/detail-sidebar/server', () => ({
|
||||
getInitialDetailSidebarMode: mocks.getInitialDetailSidebarMode,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/zendesk', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/maintenance-notice', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/main-nav/layout', () => ({
|
||||
default: ({ children, detailSidebar }: { children: ReactNode; detailSidebar: ReactNode }) => (
|
||||
<>
|
||||
{detailSidebar}
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/next-route-state', () => ({
|
||||
NextRouteStateBridge: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('../global-mounts', () => ({
|
||||
CommonLayoutGlobalMounts: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../providers', () => ({
|
||||
ConsoleContextProviders: ({ children }: { children: ReactNode }) => children,
|
||||
ConsoleRuntimeProviders: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
function DetailSidebarModeValue() {
|
||||
return <aside aria-label="Detail sidebar">{useAtomValue(detailSidebarModeAtom)}</aside>
|
||||
}
|
||||
|
||||
describe('CommonLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes detail sidebar state from the request snapshot', async () => {
|
||||
mocks.getInitialDetailSidebarMode.mockResolvedValue('collapse')
|
||||
const { default: CommonLayout } = await import('../layout')
|
||||
|
||||
const layout = await CommonLayout({
|
||||
children: <main>Content</main>,
|
||||
detailSidebar: <DetailSidebarModeValue />,
|
||||
})
|
||||
render(<Provider>{layout}</Provider>)
|
||||
|
||||
expect(screen.getByRole('complementary', { name: 'Detail sidebar' })).toHaveTextContent(
|
||||
'collapse',
|
||||
)
|
||||
expect(screen.getByRole('main')).toHaveTextContent('Content')
|
||||
expect(mocks.getInitialDetailSidebarMode).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { getInitialDetailSidebarMode } from '@/app/components/detail-sidebar/server'
|
||||
import { DetailSidebarStateInitializer } from '@/app/components/detail-sidebar/state-initializer'
|
||||
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
|
||||
import MainNavLayout from '@/app/components/main-nav/layout'
|
||||
import { NextRouteStateBridge } from '@/app/components/next-route-state'
|
||||
@@ -13,19 +15,23 @@ export default async function Layout({
|
||||
children: React.ReactNode
|
||||
detailSidebar: React.ReactNode
|
||||
}) {
|
||||
const initialDetailSidebarMode = await getInitialDetailSidebarMode()
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ConsoleRuntimeProviders>
|
||||
<NextRouteStateBridge>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<MaintenanceNotice />
|
||||
<ConsoleContextProviders>
|
||||
<MainNavLayout detailSidebar={detailSidebar}>{children}</MainNavLayout>
|
||||
<CommonLayoutGlobalMounts />
|
||||
</ConsoleContextProviders>
|
||||
</div>
|
||||
</NextRouteStateBridge>
|
||||
</ConsoleRuntimeProviders>
|
||||
<DetailSidebarStateInitializer initialMode={initialDetailSidebarMode}>
|
||||
<ConsoleRuntimeProviders>
|
||||
<NextRouteStateBridge>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<MaintenanceNotice />
|
||||
<ConsoleContextProviders>
|
||||
<MainNavLayout detailSidebar={detailSidebar}>{children}</MainNavLayout>
|
||||
<CommonLayoutGlobalMounts />
|
||||
</ConsoleContextProviders>
|
||||
</div>
|
||||
</NextRouteStateBridge>
|
||||
</ConsoleRuntimeProviders>
|
||||
</DetailSidebarStateInitializer>
|
||||
<Zendesk />
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { DETAIL_SIDEBAR_COOKIE_NAME } from '@/app/components/detail-sidebar/preference'
|
||||
import { updateAppModelConfig } from '@/service/apps'
|
||||
import { renderHook } from '@/test/console/render'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
@@ -8,7 +10,6 @@ import { useConfiguration } from '../use-configuration'
|
||||
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockSetShowAppConfigureFeaturesModal = vi.fn()
|
||||
const mockSetDetailSidebarMode = vi.fn()
|
||||
const mockHandleMultipleModelConfigsChange = vi.fn()
|
||||
const mockFetchCollectionList = vi.fn()
|
||||
const mockFetchAppDetailDirect = vi.fn()
|
||||
@@ -101,10 +102,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/detail-sidebar/storage', () => ({
|
||||
useSetDetailSidebarMode: () => mockSetDetailSidebarMode,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({
|
||||
data: undefined,
|
||||
@@ -195,6 +192,7 @@ vi.mock('@/utils/completion-params', () => ({
|
||||
describe('useConfiguration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME, { path: '/' })
|
||||
latestAdvancedPromptConfigOptions = undefined
|
||||
mockTempStopState = []
|
||||
mockCurrentModelFeatures = ['vision']
|
||||
@@ -492,7 +490,7 @@ describe('useConfiguration', () => {
|
||||
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true)
|
||||
expect(mockFormattingChangedDispatcher).toHaveBeenCalled()
|
||||
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalled()
|
||||
expect(mockSetDetailSidebarMode).toHaveBeenCalledWith('collapse')
|
||||
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse')
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
|
||||
expect(mockSetConversationHistoriesRole).toHaveBeenCalledWith({
|
||||
assistant_prefix: 'bot',
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { VisionSettings } from '@/types/app'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { clone } from 'es-toolkit/object'
|
||||
import { produce } from 'immer'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from '@/app/components/app/configuration/debug/hooks'
|
||||
import useAdvancedPromptConfig from '@/app/components/app/configuration/hooks/use-advanced-prompt-config'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useSetDetailSidebarMode } from '@/app/components/detail-sidebar/storage'
|
||||
import { detailSidebarModeAtom } from '@/app/components/detail-sidebar/state'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import {
|
||||
ModelFeatureEnum,
|
||||
@@ -138,7 +138,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
|
||||
})),
|
||||
)
|
||||
const setDetailSidebarMode = useSetDetailSidebarMode()
|
||||
const setDetailSidebarMode = useSetAtom(detailSidebarModeAtom)
|
||||
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Detail Sidebar
|
||||
|
||||
Owns the shared console detail sidebar shell, interaction state, and SSR-aware Cookie preference.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
- `index`: Shared sidebar frame and expand, collapse, hover-preview, and shortcut interactions.
|
||||
- `preference`: Cookie name, runtime validation, and the default sidebar mode.
|
||||
- `server`: Request Cookie adapter that resolves the initial sidebar mode.
|
||||
- `state`: Feature-level Jotai state, initialization command, and client-side Cookie persistence.
|
||||
- `state-initializer`: Initializes the feature atom from the request snapshot during render.
|
||||
- `hotkeys`: Shared keyboard shortcut contract.
|
||||
- `toggle-button`: Sidebar toggle presentation.
|
||||
|
||||
## State Ownership
|
||||
|
||||
- The Common Layout request snapshot initializes the feature atom at its surface boundary.
|
||||
- The feature atom is the client runtime source of truth.
|
||||
- Public atom writes update Jotai synchronously and then attempt to persist the same mode to the Cookie.
|
||||
- Store initialization never writes the Cookie, and Cookie changes outside this feature are not synchronized back at runtime.
|
||||
|
||||
## External Modules
|
||||
|
||||
- `app/components/header/env-nav`: Environment metadata shown in the expanded sidebar.
|
||||
- `app/components/main-nav/components/account-section`: Shared account control.
|
||||
- `app/components/main-nav/components/help-menu`: Shared help control.
|
||||
- `context/version-state`: Current environment state.
|
||||
@@ -1,7 +1,8 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { render } from '@/test/console/render'
|
||||
import { DetailSidebarFrame } from '..'
|
||||
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
|
||||
import { DETAIL_SIDEBAR_COOKIE_NAME } from '../preference'
|
||||
|
||||
const { hotkeyRegistrations } = vi.hoisted(() => ({
|
||||
hotkeyRegistrations: new Map<
|
||||
@@ -80,7 +81,7 @@ function renderDetailSidebarFrame() {
|
||||
|
||||
describe('DetailSidebarFrame', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME, { path: '/' })
|
||||
hotkeyRegistrations.clear()
|
||||
mockConsoleState.current = {
|
||||
langGeniusVersionInfo: {
|
||||
@@ -123,7 +124,7 @@ describe('DetailSidebarFrame', () => {
|
||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'false')
|
||||
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'false')
|
||||
expect(screen.queryByText('Environment tag')).not.toBeInTheDocument()
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse')
|
||||
})
|
||||
|
||||
it('shows a floating preview on collapsed hover without changing persisted state', () => {
|
||||
@@ -134,7 +135,7 @@ describe('DetailSidebarFrame', () => {
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
|
||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse')
|
||||
})
|
||||
|
||||
it('persists expansion without width animation when the hovered preview toggle is clicked', () => {
|
||||
@@ -147,6 +148,6 @@ describe('DetailSidebarFrame', () => {
|
||||
expect(screen.getByRole('complementary')).not.toHaveClass('overflow-visible')
|
||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('expand')
|
||||
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('expand')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { parseDetailSidebarMode } from '../preference'
|
||||
|
||||
describe('parseDetailSidebarMode', () => {
|
||||
it.each(['expand', 'collapse'] as const)('accepts the %s mode', (mode) => {
|
||||
expect(parseDetailSidebarMode(mode)).toBe(mode)
|
||||
})
|
||||
|
||||
it.each([undefined, '', 'expanded', 'COLLAPSE'])('rejects %s', (raw) => {
|
||||
expect(parseDetailSidebarMode(raw)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { DETAIL_SIDEBAR_COOKIE_NAME } from '../preference'
|
||||
import { getInitialDetailSidebarMode } from '../server'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getCookie: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/headers', () => ({
|
||||
cookies: async () => ({ get: mocks.getCookie }),
|
||||
}))
|
||||
|
||||
describe('getInitialDetailSidebarMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('reads a valid mode from the request Cookie', async () => {
|
||||
mocks.getCookie.mockReturnValue({ value: 'collapse' })
|
||||
|
||||
await expect(getInitialDetailSidebarMode()).resolves.toBe('collapse')
|
||||
expect(mocks.getCookie).toHaveBeenCalledWith(DETAIL_SIDEBAR_COOKIE_NAME)
|
||||
})
|
||||
|
||||
it.each([undefined, { value: 'invalid' }])('uses the default mode for %s', async (cookie) => {
|
||||
mocks.getCookie.mockReturnValue(cookie)
|
||||
|
||||
await expect(getInitialDetailSidebarMode()).resolves.toBe('expand')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { DetailSidebarMode } from '../preference'
|
||||
import { Provider, useAtomValue } from 'jotai'
|
||||
import { act } from 'react'
|
||||
import { hydrateRoot } from 'react-dom/client'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { detailSidebarModeAtom } from '../state'
|
||||
import { DetailSidebarStateInitializer } from '../state-initializer'
|
||||
|
||||
function DetailSidebarModeValue() {
|
||||
return <span>{useAtomValue(detailSidebarModeAtom)}</span>
|
||||
}
|
||||
|
||||
function renderInitializer(initialMode: DetailSidebarMode) {
|
||||
return (
|
||||
<Provider>
|
||||
<DetailSidebarStateInitializer initialMode={initialMode}>
|
||||
<DetailSidebarModeValue />
|
||||
</DetailSidebarStateInitializer>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DetailSidebarStateInitializer', () => {
|
||||
it('uses the same request snapshot for server rendering and client hydration', async () => {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = renderToString(renderInitializer('collapse'))
|
||||
document.body.append(container)
|
||||
const recoverableErrors: unknown[] = []
|
||||
|
||||
expect(container).toHaveTextContent('collapse')
|
||||
|
||||
const root = hydrateRoot(container, renderInitializer('collapse'), {
|
||||
onRecoverableError: (error) => recoverableErrors.push(error),
|
||||
})
|
||||
await act(async () => {})
|
||||
|
||||
expect(container).toHaveTextContent('collapse')
|
||||
expect(recoverableErrors).toEqual([])
|
||||
|
||||
await act(async () => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createStore } from 'jotai'
|
||||
import Cookies from 'js-cookie'
|
||||
import { DETAIL_SIDEBAR_COOKIE_NAME } from '../preference'
|
||||
import { detailSidebarModeAtom, initializeDetailSidebarModeAtom } from '../state'
|
||||
|
||||
describe('detailSidebarModeAtom', () => {
|
||||
beforeEach(() => {
|
||||
Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME, { path: '/' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('initializes the atom without writing the Cookie', () => {
|
||||
const store = createStore()
|
||||
const setCookie = vi.spyOn(Cookies, 'set')
|
||||
|
||||
store.set(initializeDetailSidebarModeAtom, 'collapse')
|
||||
|
||||
expect(store.get(detailSidebarModeAtom)).toBe('collapse')
|
||||
expect(setCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates the atom first and persists the next value with the fixed attributes', () => {
|
||||
const store = createStore()
|
||||
const setCookie = vi.spyOn(Cookies, 'set').mockImplementation((_name, value) => {
|
||||
expect(store.get(detailSidebarModeAtom)).toBe(value)
|
||||
return undefined
|
||||
})
|
||||
|
||||
store.set(detailSidebarModeAtom, (mode) => (mode === 'expand' ? 'collapse' : 'expand'))
|
||||
|
||||
expect(store.get(detailSidebarModeAtom)).toBe('collapse')
|
||||
expect(setCookie).toHaveBeenCalledWith(DETAIL_SIDEBAR_COOKIE_NAME, 'collapse', {
|
||||
expires: 365,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the atom update when Cookie persistence throws', () => {
|
||||
const store = createStore()
|
||||
vi.spyOn(Cookies, 'set').mockImplementation(() => {
|
||||
throw new Error('Cookie access unavailable')
|
||||
})
|
||||
|
||||
expect(() => store.set(detailSidebarModeAtom, 'collapse')).not.toThrow()
|
||||
expect(store.get(detailSidebarModeAtom)).toBe('collapse')
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import EnvNav from '@/app/components/header/env-nav'
|
||||
import AccountSection from '@/app/components/main-nav/components/account-section'
|
||||
import HelpMenu from '@/app/components/main-nav/components/help-menu'
|
||||
import { langGeniusVersionInfoAtom } from '@/context/version-state'
|
||||
import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys'
|
||||
import { useDetailSidebarMode } from './storage'
|
||||
import { detailSidebarModeAtom } from './state'
|
||||
|
||||
type DetailSidebarRenderProps = {
|
||||
expand: boolean
|
||||
@@ -39,8 +39,7 @@ export function DetailSidebarFrame({
|
||||
renderSection,
|
||||
}: DetailSidebarFrameProps) {
|
||||
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
|
||||
const [storedDetailSidebarExpand, setStoredDetailSidebarExpand] = useDetailSidebarMode()
|
||||
const detailNavigationMode = storedDetailSidebarExpand === 'collapse' ? 'collapse' : 'expand'
|
||||
const [detailNavigationMode, setDetailNavigationMode] = useAtom(detailSidebarModeAtom)
|
||||
const detailNavigationExpanded = detailNavigationMode === 'expand'
|
||||
const [detailNavigationHoverPreviewOpen, setDetailNavigationHoverPreviewOpen] = useState(false)
|
||||
const [detailNavigationTransitionDisabled, setDetailNavigationTransitionDisabled] =
|
||||
@@ -64,7 +63,7 @@ export function DetailSidebarFrame({
|
||||
|
||||
setDetailNavigationTransitionDisabled(true)
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
setStoredDetailSidebarExpand('expand')
|
||||
setDetailNavigationMode('expand')
|
||||
detailNavigationTransitionTimerRef.current = setTimeout(() => {
|
||||
setDetailNavigationTransitionDisabled(false)
|
||||
}, 200)
|
||||
@@ -73,7 +72,7 @@ export function DetailSidebarFrame({
|
||||
|
||||
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
setStoredDetailSidebarExpand(nextMode)
|
||||
setDetailNavigationMode(nextMode)
|
||||
}
|
||||
|
||||
function openDetailNavigationHoverPreview() {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type DetailSidebarMode = 'expand' | 'collapse'
|
||||
|
||||
export const DETAIL_SIDEBAR_COOKIE_NAME = 'console-detail-sidebar-mode'
|
||||
export const DEFAULT_DETAIL_SIDEBAR_MODE: DetailSidebarMode = 'expand'
|
||||
|
||||
export function parseDetailSidebarMode(raw: string | undefined): DetailSidebarMode | undefined {
|
||||
if (raw === 'expand' || raw === 'collapse') return raw
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cookies } from '@/next/headers'
|
||||
import {
|
||||
DEFAULT_DETAIL_SIDEBAR_MODE,
|
||||
DETAIL_SIDEBAR_COOKIE_NAME,
|
||||
parseDetailSidebarMode,
|
||||
} from './preference'
|
||||
|
||||
export async function getInitialDetailSidebarMode() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
return (
|
||||
parseDetailSidebarMode(cookieStore.get(DETAIL_SIDEBAR_COOKIE_NAME)?.value) ??
|
||||
DEFAULT_DETAIL_SIDEBAR_MODE
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DetailSidebarMode } from './preference'
|
||||
import { useHydrateAtoms } from 'jotai/react/utils'
|
||||
import { initializeDetailSidebarModeAtom } from './state'
|
||||
|
||||
export function DetailSidebarStateInitializer({
|
||||
children,
|
||||
initialMode,
|
||||
}: {
|
||||
children: ReactNode
|
||||
initialMode: DetailSidebarMode
|
||||
}) {
|
||||
useHydrateAtoms([[initializeDetailSidebarModeAtom, initialMode]])
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import type { SetStateAction } from 'jotai'
|
||||
import type { DetailSidebarMode } from './preference'
|
||||
import { atom } from 'jotai'
|
||||
import Cookies from 'js-cookie'
|
||||
import { DEFAULT_DETAIL_SIDEBAR_MODE, DETAIL_SIDEBAR_COOKIE_NAME } from './preference'
|
||||
|
||||
const detailSidebarModeBaseAtom = atom(DEFAULT_DETAIL_SIDEBAR_MODE)
|
||||
|
||||
function persistDetailSidebarMode(mode: DetailSidebarMode) {
|
||||
try {
|
||||
Cookies.set(DETAIL_SIDEBAR_COOKIE_NAME, mode, {
|
||||
expires: 365,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: globalThis.location.protocol === 'https:',
|
||||
})
|
||||
} catch {
|
||||
// Cookie persistence is best-effort; Jotai remains the runtime source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
export const detailSidebarModeAtom = atom(
|
||||
(get) => get(detailSidebarModeBaseAtom),
|
||||
(get, set, update: SetStateAction<DetailSidebarMode>) => {
|
||||
const nextMode = typeof update === 'function' ? update(get(detailSidebarModeBaseAtom)) : update
|
||||
|
||||
set(detailSidebarModeBaseAtom, nextMode)
|
||||
persistDetailSidebarMode(nextMode)
|
||||
},
|
||||
)
|
||||
|
||||
export const initializeDetailSidebarModeAtom = atom(null, (_get, set, mode: DetailSidebarMode) => {
|
||||
set(detailSidebarModeBaseAtom, mode)
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createLocalStorageState } from 'foxact/create-local-storage-state'
|
||||
|
||||
type DetailSidebarMode = 'expand' | 'collapse'
|
||||
|
||||
export const DETAIL_SIDEBAR_STORAGE_KEY = 'app-detail-collapse-or-expand'
|
||||
|
||||
const [useDetailSidebarMode, _useDetailSidebarModeValue, useSetDetailSidebarMode] =
|
||||
createLocalStorageState<DetailSidebarMode>(DETAIL_SIDEBAR_STORAGE_KEY, 'expand', { raw: true })
|
||||
|
||||
export { useDetailSidebarMode, useSetDetailSidebarMode }
|
||||
@@ -16,7 +16,6 @@ import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { queryClientAtom } from 'jotai-tanstack-query'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage'
|
||||
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
@@ -656,7 +655,6 @@ describe('MainNav', () => {
|
||||
})
|
||||
|
||||
it('keeps the global navigation account section expanded on home routes', () => {
|
||||
localStorage.setItem(DETAIL_SIDEBAR_STORAGE_KEY, 'collapse')
|
||||
mockPathname = '/'
|
||||
|
||||
renderMainNav()
|
||||
|
||||
Reference in New Issue
Block a user