mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 13:01:16 +08:00
feat(web): refine snippet siderbar
This commit is contained in:
parent
3e606ff0dc
commit
16a0cfc40c
107
web/app/components/main-nav/components/snippet-detail-top.tsx
Normal file
107
web/app/components/main-nav/components/snippet-detail-top.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import ToggleButton from '../../app-sidebar/toggle-button'
|
||||
|
||||
type SnippetDetailTopProps = {
|
||||
expand?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
|
||||
const SnippetDetailTop = ({
|
||||
expand = true,
|
||||
onToggle,
|
||||
}: SnippetDetailTopProps) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center py-2 pr-2 pl-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-px">
|
||||
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.back', { ns: 'common' })}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t('mainNav.home', { ns: 'common' })}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<span className="shrink-0 system-md-regular text-text-quaternary">
|
||||
/
|
||||
</span>
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="shrink-0 truncate rounded-lg px-1.5 py-2 system-sm-semibold-uppercase text-text-secondary transition-colors hover:bg-background-default-hover hover:text-text-primary"
|
||||
>
|
||||
{t('tabs.snippets', { ns: 'workflow' })}
|
||||
</Link>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('gotoAnything.searchTitle', { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => setGotoAnythingOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent placement="bottom" className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 system-xs-medium text-text-secondary shadow-lg backdrop-blur-[5px]">
|
||||
<span className="px-0.5">{t('gotoAnything.quickAction', { ns: 'app' })}</span>
|
||||
<KbdGroup>
|
||||
{SEARCH_SHORTCUT.map(key => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetDetailTop
|
||||
@ -14,6 +14,9 @@ import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import EnvNav from '@/app/components/header/env-nav'
|
||||
// import { buildIntegrationPath } from '@/app/components/integrations/routes'
|
||||
// import { SnippetSidebarContent } from '@/app/components/snippets/components/snippet-sidebar'
|
||||
// import { useSnippetDetailStore } from '@/app/components/snippets/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { AgentDetailSection, AgentDetailTop } from '@/features/agent-v2/agent-detail/navigation'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
@ -25,6 +28,7 @@ import AccountSection from './components/account-section'
|
||||
import HelpMenu from './components/help-menu'
|
||||
import MainNavLink from './components/nav-link'
|
||||
import { MainNavSearchButton } from './components/search-button'
|
||||
// import SnippetDetailTop from './components/snippet-detail-top'
|
||||
import WebAppsSection from './components/web-apps-section'
|
||||
import { WorkspaceCard } from './components/workspace-card'
|
||||
import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
|
||||
@ -111,9 +115,7 @@ const MainNav = ({
|
||||
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isDetailNavigationHoverPreviewOpen = isCollapsedDetailNavigation && detailNavigationHoverPreviewOpen
|
||||
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
|
||||
const bottomNavigationExpanded = showSnippetDetailBottomNavigation
|
||||
? false
|
||||
: !showDetailNavigation || detailNavigationVisibleExpanded
|
||||
const bottomNavigationExpanded = !showDetailNavigation || detailNavigationVisibleExpanded
|
||||
const handleToggleDetailNavigation = useCallback(() => {
|
||||
if (isDetailNavigationHoverPreviewOpen) {
|
||||
if (detailNavigationTransitionTimerRef.current)
|
||||
@ -222,9 +224,7 @@ const MainNav = ({
|
||||
? detailNavigationExpanded
|
||||
? 'w-[248px] bg-background-body p-1'
|
||||
: 'w-16 bg-background-body p-1'
|
||||
: showSnippetDetailBottomNavigation
|
||||
? 'w-16 bg-background-body p-1'
|
||||
: 'w-60 flex-col',
|
||||
: 'w-60 flex-col',
|
||||
'bg-background-body',
|
||||
className,
|
||||
)}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
|
||||
import type { SnippetDetail, SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
@ -13,6 +13,7 @@ const mockDoSyncWorkflowDraft = vi.fn()
|
||||
const mockSyncWorkflowDraftWhenPageClose = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockSetFields = vi.fn()
|
||||
const mockSetNavigationState = vi.fn()
|
||||
const mockPublishSnippetMutateAsync = vi.fn()
|
||||
const mockUseSnippetPublishedWorkflow = vi.fn()
|
||||
const mockFetchInspectVars = vi.fn()
|
||||
@ -62,8 +63,13 @@ let capturedHooksStore: Record<string, unknown> | undefined
|
||||
let capturedWorkflowNodes: WorkflowProps['nodes'] | undefined
|
||||
let snippetDetailStoreState: {
|
||||
fields: SnippetInputField[]
|
||||
onFieldsChange?: (fields: SnippetInputField[]) => void
|
||||
readonly: boolean
|
||||
reset: typeof mockReset
|
||||
setFields: typeof mockSetFields
|
||||
setNavigationState: typeof mockSetNavigationState
|
||||
snippet?: SnippetDetail
|
||||
snippetId?: string
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/snippets/store', () => ({
|
||||
@ -190,34 +196,6 @@ vi.mock('@/app/components/snippets/components/snippet-children', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
|
||||
default: ({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
}: {
|
||||
fields: SnippetInputField[]
|
||||
onFieldsChange: (fields: SnippetInputField[]) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onFieldsChange([])}>remove</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFieldsChange([
|
||||
...fields,
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
},
|
||||
])}
|
||||
>
|
||||
submit
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const payload: SnippetDetailPayload = {
|
||||
snippet: {
|
||||
id: 'snippet-1',
|
||||
@ -328,12 +306,20 @@ describe('SnippetMain', () => {
|
||||
},
|
||||
})
|
||||
mockHandleCheckBeforePublish.mockResolvedValue(true)
|
||||
mockSetNavigationState.mockImplementation((state) => {
|
||||
snippetDetailStoreState = {
|
||||
...snippetDetailStoreState,
|
||||
...state,
|
||||
}
|
||||
})
|
||||
capturedHooksStore = undefined
|
||||
capturedWorkflowNodes = undefined
|
||||
snippetDetailStoreState = {
|
||||
fields: [...payload.inputFields],
|
||||
readonly: true,
|
||||
reset: mockReset,
|
||||
setFields: mockSetFields,
|
||||
setNavigationState: mockSetNavigationState,
|
||||
}
|
||||
mockWorkspacePermissionKeys.value = ['snippets.create_and_modify']
|
||||
})
|
||||
@ -414,7 +400,12 @@ describe('SnippetMain', () => {
|
||||
it('should sync draft input_fields when removing a field from the panel', async () => {
|
||||
renderSnippetMain({ currentNodes: [createDraftNode()] })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
|
||||
await waitFor(() => {
|
||||
expect(snippetDetailStoreState.onFieldsChange).toEqual(expect.any(Function))
|
||||
})
|
||||
act(() => {
|
||||
snippetDetailStoreState.onFieldsChange?.([])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
|
||||
@ -426,7 +417,20 @@ describe('SnippetMain', () => {
|
||||
it('should sync draft input_fields when adding a field from the sidebar', async () => {
|
||||
renderSnippetMain({ currentNodes: [createDraftNode()] })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
|
||||
await waitFor(() => {
|
||||
expect(snippetDetailStoreState.onFieldsChange).toEqual(expect.any(Function))
|
||||
})
|
||||
act(() => {
|
||||
snippetDetailStoreState.onFieldsChange?.([
|
||||
...payload.inputFields,
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
|
||||
|
||||
@ -53,20 +53,6 @@ vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
href: string
|
||||
className?: string
|
||||
}) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/start/components/var-list', () => ({
|
||||
default: (props: {
|
||||
list: InputVar[]
|
||||
@ -155,7 +141,11 @@ describe('SnippetSidebar', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: /snippet\.management/i })).toHaveAttribute('href', '/snippets')
|
||||
expect(screen.queryByRole('link', { name: /snippet\.management/i })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(snippet.name)).toHaveAttribute('title', snippet.name)
|
||||
expect(screen.getByText(snippet.name)).toHaveClass('truncate')
|
||||
expect(screen.getByText(snippet.description)).toHaveAttribute('title', snippet.description)
|
||||
expect(screen.getByText(snippet.description)).toHaveClass('truncate')
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.add/i })).not.toBeInTheDocument()
|
||||
expect(capturedVarListProps?.readonly).toBe(true)
|
||||
})
|
||||
|
||||
@ -39,7 +39,6 @@ import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-act
|
||||
import { useSnippetPublish } from './hooks/use-snippet-publish'
|
||||
import SaveBeforeLeavingDialog from './save-before-leaving-dialog'
|
||||
import SnippetChildren from './snippet-children'
|
||||
import SnippetSidebar from './snippet-sidebar'
|
||||
|
||||
type SnippetMainProps = {
|
||||
payload: SnippetDetailPayload
|
||||
@ -365,9 +364,11 @@ const SnippetMain = ({
|
||||
const {
|
||||
reset,
|
||||
setFields,
|
||||
setNavigationState,
|
||||
} = useSnippetDetailStore(useShallow(state => ({
|
||||
reset: state.reset,
|
||||
setFields: state.setFields,
|
||||
setNavigationState: state.setNavigationState,
|
||||
})))
|
||||
const {
|
||||
fields,
|
||||
@ -385,6 +386,8 @@ const SnippetMain = ({
|
||||
const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl(snippetId)
|
||||
useEffect(() => {
|
||||
reset()
|
||||
|
||||
return () => reset()
|
||||
}, [reset, snippetId])
|
||||
|
||||
useEffect(() => {
|
||||
@ -447,6 +450,15 @@ const SnippetMain = ({
|
||||
setHasDraftChanges(true)
|
||||
}, [canCreateAndModifySnippet, handleFieldsChange, isEditing, setHasDraftChanges])
|
||||
|
||||
useEffect(() => {
|
||||
setNavigationState({
|
||||
snippetId,
|
||||
snippet,
|
||||
readonly: !isEditing,
|
||||
onFieldsChange: handleFieldsChangeInEditing,
|
||||
})
|
||||
}, [handleFieldsChangeInEditing, isEditing, setNavigationState, snippet, snippetId])
|
||||
|
||||
const updateLocalDraftFromSyncPayload = useCallback((
|
||||
syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | void,
|
||||
) => {
|
||||
@ -600,12 +612,6 @@ const SnippetMain = ({
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 min-w-0">
|
||||
<SnippetSidebar
|
||||
snippet={snippet}
|
||||
fields={fields}
|
||||
readonly={!isEditing}
|
||||
onFieldsChange={handleFieldsChangeInEditing}
|
||||
/>
|
||||
<div className="relative min-h-0 min-w-0 grow">
|
||||
<WorkflowWithInnerContext
|
||||
key={`${snippetId}-${isEditing ? 'draft' : 'published'}`}
|
||||
|
||||
@ -11,7 +11,6 @@ import SnippetInfoDropdown from '@/app/components/app-sidebar/snippet-info/dropd
|
||||
import ConfigVarModal from '@/app/components/app/configuration/config-var/config-modal'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import VarList from '@/app/components/workflow/nodes/start/components/var-list'
|
||||
import Link from '@/next/link'
|
||||
import { hasDuplicateStr } from '@/utils/var'
|
||||
|
||||
type SnippetSidebarProps = {
|
||||
@ -21,6 +20,10 @@ type SnippetSidebarProps = {
|
||||
onFieldsChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
type SnippetSidebarContentProps = SnippetSidebarProps & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const toWorkflowInputVar = (field: SnippetInputField): InputVar => ({
|
||||
...field,
|
||||
type: field.type as unknown as InputVar['type'],
|
||||
@ -32,12 +35,13 @@ const toSnippetInputField = (field: InputVar): SnippetInputField => ({
|
||||
type: field.type as unknown as SnippetInputField['type'],
|
||||
})
|
||||
|
||||
const SnippetSidebar = ({
|
||||
export const SnippetSidebarContent = ({
|
||||
snippet,
|
||||
fields,
|
||||
readonly,
|
||||
onFieldsChange,
|
||||
}: SnippetSidebarProps) => {
|
||||
className,
|
||||
}: SnippetSidebarContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isShowAddVarModal, setIsShowAddVarModal] = useState(false)
|
||||
const workflowInputVars = useMemo(() => fields.map(toWorkflowInputVar), [fields])
|
||||
@ -88,21 +92,13 @@ const SnippetSidebar = ({
|
||||
}, [fields, onFieldsChange])
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-90 shrink-0 flex-col overflow-hidden rounded-tl-2xl border-r border-divider-subtle bg-background-default">
|
||||
<div className="shrink-0 px-6 pt-7">
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="inline-flex items-center gap-2 system-sm-semibold-uppercase text-text-primary hover:text-text-accent"
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-line h-4 w-4" />
|
||||
{t('management', { ns: 'snippet' })}
|
||||
</Link>
|
||||
|
||||
<div className="mt-12 flex items-start gap-3">
|
||||
<div className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-background-default', className)}>
|
||||
<div className="shrink-0 px-3 py-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 grow">
|
||||
<div className="system-xl-semibold text-text-primary">{snippet.name}</div>
|
||||
<div className="truncate system-xl-semibold text-text-primary" title={snippet.name}>{snippet.name}</div>
|
||||
{!!snippet.description && (
|
||||
<div className="mt-3 system-sm-regular text-text-tertiary">
|
||||
<div className="mt-3 truncate system-sm-regular text-text-tertiary" title={snippet.description}>
|
||||
{snippet.description}
|
||||
</div>
|
||||
)}
|
||||
@ -111,9 +107,7 @@ const SnippetSidebar = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-6 mt-7 h-px shrink-0 bg-divider-subtle" />
|
||||
|
||||
<div className="flex min-h-0 grow flex-col px-6 pt-7">
|
||||
<div className="flex min-h-0 grow flex-col px-3 pt-6">
|
||||
<Field
|
||||
title={t('inputVariables', { ns: 'snippet' })}
|
||||
operations={!readonly
|
||||
@ -151,6 +145,14 @@ const SnippetSidebar = ({
|
||||
varKeys={fields.map(v => v.variable)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetSidebar = (props: SnippetSidebarProps) => {
|
||||
return (
|
||||
<aside className="flex h-full w-90 shrink-0 flex-col overflow-hidden rounded-tl-2xl border-r border-divider-subtle bg-background-default">
|
||||
<SnippetSidebarContent {...props} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetDetailStore } from '..'
|
||||
|
||||
@ -9,6 +9,15 @@ const createField = (variable: string): SnippetInputField => ({
|
||||
required: true,
|
||||
})
|
||||
|
||||
const snippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Snippet',
|
||||
description: 'Description',
|
||||
updatedAt: '2026-03-29 10:00',
|
||||
usage: '0',
|
||||
tags: [],
|
||||
}
|
||||
|
||||
describe('useSnippetDetailStore', () => {
|
||||
beforeEach(() => {
|
||||
useSnippetDetailStore.getState().reset()
|
||||
@ -28,4 +37,32 @@ describe('useSnippetDetailStore', () => {
|
||||
|
||||
expect(useSnippetDetailStore.getState().fields).toEqual([])
|
||||
})
|
||||
|
||||
it('should store and reset snippet navigation state', () => {
|
||||
const onFieldsChange = vi.fn()
|
||||
|
||||
useSnippetDetailStore.getState().setNavigationState({
|
||||
snippetId: 'snippet-1',
|
||||
snippet,
|
||||
readonly: false,
|
||||
onFieldsChange,
|
||||
})
|
||||
|
||||
expect(useSnippetDetailStore.getState()).toMatchObject({
|
||||
snippetId: 'snippet-1',
|
||||
snippet,
|
||||
readonly: false,
|
||||
onFieldsChange,
|
||||
})
|
||||
|
||||
useSnippetDetailStore.getState().reset()
|
||||
|
||||
expect(useSnippetDetailStore.getState()).toMatchObject({
|
||||
fields: [],
|
||||
readonly: true,
|
||||
snippet: undefined,
|
||||
snippetId: undefined,
|
||||
onFieldsChange: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,20 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type SnippetNavigationState = {
|
||||
snippet?: SnippetDetail
|
||||
snippetId?: string
|
||||
readonly: boolean
|
||||
onFieldsChange?: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
type SnippetDetailUIState = {
|
||||
fields: SnippetInputField[]
|
||||
setFields: (fields: SnippetInputField[]) => void
|
||||
setNavigationState: (state: SnippetNavigationState) => void
|
||||
reset: () => void
|
||||
}
|
||||
} & SnippetNavigationState
|
||||
|
||||
const initialState = {
|
||||
fields: [] as SnippetInputField[],
|
||||
readonly: true,
|
||||
snippet: undefined,
|
||||
snippetId: undefined,
|
||||
onFieldsChange: undefined,
|
||||
}
|
||||
|
||||
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
|
||||
...initialState,
|
||||
setFields: fields => set({ fields }),
|
||||
setNavigationState: state => set(state),
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user