diff --git a/web/app/components/main-nav/components/snippet-detail-top.tsx b/web/app/components/main-nav/components/snippet-detail-top.tsx new file mode 100644 index 00000000000..2e44894d801 --- /dev/null +++ b/web/app/components/main-nav/components/snippet-detail-top.tsx @@ -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 ( +
+ {onToggle && ( + } + 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" + /> + )} +
+ ) + } + + return ( +
+
+
+ + + + +
+ + / + + + {t('tabs.snippets', { ns: 'workflow' })} + +
+ + setGotoAnythingOpen(true)} + > + + + )} + /> + + {t('gotoAnything.quickAction', { ns: 'app' })} + + {SEARCH_SHORTCUT.map(key => ( + {formatForDisplay(key)} + ))} + + + + {onToggle && ( + } + 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" + /> + )} +
+ ) +} + +export default SnippetDetailTop diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 6e11df51839..e5bf47f5258 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -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 | 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, )} diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx index 5be724ce257..4d39d5623b2 100644 --- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx @@ -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 | 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 - }) => ( -
- - -
- ), -})) - 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([ diff --git a/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx index d7163d225e8..f7ce2d2013a 100644 --- a/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx @@ -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 - }) => ( - {children} - ), -})) - 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) }) diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index 9f9ecab698a..a33300a4b62 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -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 | void, ) => { @@ -600,12 +612,6 @@ const SnippetMain = ({ return (
-
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 ( -