chore(web): clean up unused production code (#37854)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-06-24 15:10:17 +08:00 committed by GitHub
parent 347b02d318
commit f665bcac95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
438 changed files with 628 additions and 33238 deletions

View File

@ -1,71 +1,59 @@
---
name: how-to-write-component
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
---
# How To Write A Component
Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation.
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch.
## First Decisions
| Question | Default | Promote or extract only when |
| --- | --- | --- |
| Where should code live? | Keep it local to the feature workflow, route, or owner. | Multiple verticals need the same stable primitive. |
| Who owns state, data, and handlers? | The lowest component that uses them. | A parent coordinates shared loading, errors, empty UI, selection, submission, navigation, or one consistent snapshot. |
| Should this become Jotai state? | Keep synchronous UI/form state in component or DOM state. | Siblings need one source of truth, the value drives atoms, or scoped workflow state must survive hidden/unmounted steps. |
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
## Core Defaults
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Prefer local code and purpose-named helpers over catch-all utility modules; do not group workflow-specific defaults, validation, payload shaping, or metadata merging in a generic utils file just because they share a DTO.
- Keep source/default selection, validation, and payload shaping close to the workflow that owns the behavior. Do not extract a shared helper just because two flows read the same DTO when their priority order, fallback behavior, or submit semantics differ.
- Prefer direct, readable conditionals at the use site for small branch-specific decisions, especially form source selection and request payload assembly. Extract only when the helper name captures a stable domain rule and removes repeated complexity without hiding flow-specific behavior.
- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Group feature code by workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
## Feature Workflow Layout
## Layout And Ownership
- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI.
- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions.
- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region.
- Folders represent groups of related files with a shared owner and a stable reason to change together.
- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching.
- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions.
- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache.
- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source.
- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in Query and API flow.
- Do not replace prop drilling with one large view-model hook threaded through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child, menu, or row own the action.
## Ownership
## Feature-Scoped Jotai
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth.
- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need.
- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. Do not create a query or mutation atom only because the surrounding feature uses Jotai. If the query or mutation does not read atom state, feed another atom, or participate in shared workflow orchestration, use `useQuery` or `useMutation` directly at the lowest owner.
- For repeated row/menu action surfaces that need reset, hydrate the stable identity at the surface entry and scope only the primitives that truly need per-instance reset, such as open flags, drafts, or selected local options.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Default to uncontrolled form and DOM state. Add controlled props or atom-backed drafts only when live cross-component reactions, multi-step persistence, or external synchronization require them.
## Feature-Scoped Jotai State
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, shared query atoms, derived atoms, write-only action atoms, shared mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Keep synchronous UI state local when one component owns it, even inside Jotai-backed features. Dialog open flags, menu/popover visibility, confirmation visibility, form/input drafts, and selected local options usually belong in component state.
- Do not put simple form drafts in Jotai atoms. For edit/create forms whose fields are only read at submit time, use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls with `defaultValue`, browser/form validation, and keyed remounts for query-backed initial values.
- Promote form state to Jotai only when another component must react to in-progress field changes, the draft must survive unmount/remount within the same scoped workflow, or multiple steps/surfaces share the same editable draft before submit.
- Keep submit-time normalization, dirty checks, and payload shaping beside the form submit handler. Do not create form atoms, field atoms, or derived can-save atoms only to mirror uncontrolled form values or disable a submit button.
- In Jotai-backed feature surfaces, never hand-roll async loading, error, or in-flight guards with `useState` or `useRef`. For async work that depends on atom state, feeds derived atoms, or participates in shared submission orchestration, model the work with `atomWithQuery` or `atomWithMutation`; write atoms should only update the inputs that drive those atoms. For component-owned remote work that does not participate in atom state, use TanStack Query hooks directly.
- Row-local async state should belong to the row owner. Use `useQuery` or `useMutation` directly for row actions that do not depend on atom state and are not consumed by other atoms. Use a per-instance query or mutation atom only when the row action participates in a Jotai-backed shared workflow or needs atom-scoped reset semantics.
- Promote UI state to an atom only when siblings need the same source of truth, the value drives a query or mutation atom, a parent workflow coordinates the state, or the state intentionally persists across hidden or unmounted descendants within a scoped surface.
- Reflect atom-backed surface-wide locks or invariants in every affected trigger. If only one row, menu, or dialog should be disabled, keep the pending or lock scope local to that row, menu, or dialog with the lowest-owner query/mutation hook unless it genuinely participates in shared atom state.
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
- Non-query derived atoms return a narrow value with a clear domain name; avoid pass-through aliases or bundling unrelated UI facts. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
- Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, stale-result, or in-flight concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them.
- Avoid feature hooks that aggregate form values, query results, derived state, and commands for sibling components. Prefer named derived atoms and write atoms so UI components read the exact shared fact or command they need.
- When a form library owns validation, keep submit orchestration in feature state when post-submit result or error state is shared by the surface. Avoid duplicating validation gates or request shaping in UI hooks.
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state only when they need atom inputs, provide data to derived atoms, or coordinate a shared Jotai-backed workflow.
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query and mutation atoms keep shared cache behavior through the shared QueryClient.
- Do not put `atomWithQuery`, `atomWithInfiniteQuery`, `atomWithMutation`, or broad derived orchestration atoms in a `ScopeProvider` just to reset a surface. Scoped derived atoms implicitly scope their dependencies, which can duplicate query client access and break shared invalidation. Leave query/mutation atoms unscoped; let them read scoped primitive inputs.
- Scope providers should list resettable primitive atoms and explicit hydration tuples. If a derived atom must be scoped, confirm that every dependency it implicitly scopes is meant to be private to that surface.
- For scoped primitives that are always hydrated by a `ScopeProvider` tuple, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type. This keeps missing provider hydration as a runtime invariant without leaking `T | undefined` or adding pass-through "required" derived atoms only for narrowing.
- Keep independent dialog lifecycles separate. Avoid a single discriminated "current action dialog" atom when edit, delete, and other dialogs have their own open state, loading guard, or reset behavior.
- Route-derived stable identities that do not need instance reset or scoped isolation can be hydrated at the route or layout boundary into a feature route atom. Use scoped atoms only when stale cross-instance state or per-surface reset semantics are needed.
- A Jotai-backed feature has one feature-local state file for shared primitive atoms, query atoms, derived atoms, write-only actions, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Keep component-owned synchronous UI state local even inside Jotai features: dialog open flags, menus/popovers, confirmations, field drafts, and selected local options usually belong in component state.
- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit.
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
- For scoped primitives that are always hydrated by `ScopeProvider`, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type.
- Order state files by dependency graph: types/constants, primitives, query atoms, query-data derived atoms, business/readiness derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Name derived atoms as business facts and write atoms as user or workflow commands. Components should read or write the exact atom they need with `useAtomValue` or `useSetAtom`.
- Menu/dialog `open` state usually stays local, but a scoped atom is acceptable when a composed menu plus secondary surface would otherwise pass confusing `open`/`onClose` props through unrelated layers. Scope that primitive with the surface instance so reset behavior stays local.
- Keep independent dialog lifecycles separate. Avoid one discriminated "current action dialog" atom when dialogs have separate open state, loading guards, or reset behavior.
## Components, Props, And Types
@ -74,86 +62,56 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Do not create type aliases that only rename another type. Use an alias only when it encodes a real UI concept, refinement, or reusable local contract.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
- Do not extract fallback helpers whose only behavior is hiding missing display data. The component that renders the surface owns the empty, disabled, hidden, or placeholder state.
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
## Generated API Contracts
## Generated API And Nullable Data
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
- Do not hand-write local request/response/reply/page/cache-data types that mirror generated DTOs. Import or infer the generated type.
- Do not widen generated fields or enums for compatibility. Normalize legacy input at the boundary, then return the generated field type.
- Do not repair generated or API-returned contract fields in components unless the API contract or product requirement says they need normalization. Treat enums, statuses, and presence flags as exact contract values.
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum.
- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful.
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string.
- Do not use `value || undefined` for mutation payload fields where an empty string means "clear this value". Trim or normalize at the form boundary, then preserve `''` when the API contract treats it as an intentional update.
- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors.
- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data.
- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers.
## Nullable API Data
- Prefer nullable-tolerant call boundaries. Pass API-returned types through for render-only rows, and let the component render fallback, disabled state, or nothing.
- Narrow only where a real value is required, such as mutation params, route hrefs, select values, or query input. Build that target model with `flatMap`, a local loop, or an early return so the required value is captured in the same branch.
- If design says a field is the display value, use that field. Only the final component should decide whether a nullable display value renders a placeholder, hides content, or disables an action.
- Do not wrap required arrays or fields in null-fallback helpers. Use empty collection fallbacks only for not-yet-loaded query data or genuinely nullable collections at the owning render boundary.
- Do not drop rows only to satisfy props or React keys; use a stable fallback key when possible.
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by a narrowing filter.
- Avoid truthiness type guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after those filters.
- Use type guards only for meaningful domain or runtime validation, such as enum membership, object shape, or a reusable business invariant.
- Do not hand-write DTO mirrors, widen generated fields/enums, or add parallel frontend enum/status layers unless they model product state not represented by the API.
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Presentation-only tone maps should be keyed by generated enums.
- Normalize or coerce only at real boundaries: user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters.
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `null` or `undefined` until the final boundary requiring a string.
- Do not use `value || undefined` for mutation fields where `''` means "clear this value". Trim or normalize at the form boundary, then preserve intentional empty strings.
- Prefer nullable-tolerant render props for API-returned rows. Narrow only where a real value is required, such as mutation params, route hrefs, select values, query input, or required React keys.
- Build required values in the same branch that proves them, using `flatMap`, a local loop, or an early return. Avoid truthiness guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after filters.
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by narrowing filters.
- Empty collection fallbacks are for not-yet-loaded query data or genuinely nullable collections at the owning render boundary, not for hiding required API fields.
## Queries And Mutations
- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape.
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- Do not promote a query or mutation to an atom just because the feature already has a state file. Use `atomWithQuery` or `atomWithMutation` only when the query/mutation reads atom state, is consumed by another atom, or is part of shared workflow orchestration.
- In `atomWithQuery` and `atomWithInfiniteQuery`, return generated `queryOptions()` or `infiniteOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into that call instead of spreading generated options into a hand-built object.
- When prefetch and render consume the same server request, extract local query options or a query-options atom so `queryClient.prefetchQuery(...)` and `useQuery`/`atomWithQuery` share the exact generated query options.
- In `atomWithMutation`, return generated `mutationOptions()` directly when using generated clients. Put request shaping and submit orchestration in write atoms; do not rebuild mutation option objects just to pass through the generated mutation function.
- For custom query functions that do not come from generated clients, wrap the options object with TanStack `queryOptions(...)` so query atoms still return a query options contract.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`.
- For generated oRPC `queryOptions()` / `infiniteOptions()`, keep returning the generated options directly. When required input is missing, use a whole-input branch such as `input: condition ? validInput : skipToken` together with `enabled: Boolean(condition)` so no request runs and no fake payload is built.
- Do not put `skipToken` inside a nested placeholder payload, such as `{ params: { appInstanceId: skipToken } }`. Do not create hand-written "missing queryOptions" objects or coerce required IDs to `''`.
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when the mutation is owned by one component, menu, dialog, or row and its pending/error state is not consumed by feature atoms. In Jotai-backed workflow orchestration, expose mutations from feature state with `atomWithMutation` so pending/error state stays attached to the mutation atom. For component-owned custom mutation functions, use `useMutation(mutationOptions(...))` at the owner.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
- Component or atom mutation callbacks can handle local UI feedback such as toasts, closing dialogs, or navigation. They should not replace shared invalidation or add local cache patches for shared server state.
- For overlays that may open a heavier secondary surface, prefetch server data from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when the primitive exposes `onOpenChange`. Do not mount a hidden component or subscribe to a query only to warm the cache. Do not make an otherwise uncontrolled menu controlled only for prefetching.
- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape.
- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms.
- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object.
- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`.
- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options.
- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally.
- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache.
- Do not use deprecated `useInvalid` or `useReset`.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
## Component Boundaries
## Boundaries And Overlays
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
- Use the first level below a page or tab to organize independent page sections when it adds structure. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- When a dialog, dropdown, or popover component already accepts controlled `open` state, mount the surface unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset for reset behavior instead of `{open && <Surface />}` wrappers.
- When opening a dialog from a menu item, keep the menu and dialog as sibling surfaces. Let the menu item command open the dialog through local state or scoped atoms, and mount the dialog outside the menu popup content. Avoid wrapping menu items with dialog triggers when the menu primitive already owns item activation and dismissal behavior.
- For dialogs and alert dialogs, keep the root component responsible for `open` wiring and put query/mutation hooks inside the content component when the work should only mount after the overlay opens. Do not put closed-surface remote work in the root just because the root owns the open atom.
- Prefer uncontrolled overlay roots when the library can own their open state. Use `onOpenChange` for side effects such as prefetching, and CSS/data selectors for visual open-state styling instead of adding controlled state only for observation.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, children-as-pass-through composition, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook, forwards props, or passes trigger/content through to one child, move the logic into that child or make the wrapper own a real surface.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent.
- Preserve composability by separating behavior ownership from placement ownership: an action can own trigger/open/menu content while the caller owns slots, offsets, and alignment.
- When a dialog, dropdown, or popover accepts controlled `open`, mount it unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset instead of `{open && <Surface />}` wrappers.
- When opening a dialog from a menu item, keep the menu and dialog as sibling surfaces. Let the menu command open the dialog, and mount the dialog outside menu popup content.
- For dialogs and alert dialogs, keep the root responsible for `open` wiring and put query/mutation hooks inside the content component when work should mount only after the overlay opens.
- Prefer uncontrolled overlay roots when the library can own open state. Use `onOpenChange` for side effects and CSS/data selectors for open-state styling.
- Avoid wrapper DOM unless it provides layout, semantics, accessibility, state ownership, or library integration. Avoid shallow wrappers, hook-to-props adapters, layout-only render props, children pass-through wrappers, and prop renaming unless they add real behavior or a real boundary.
## You Might Not Need An Effect
- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration.
- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive.
- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known.
- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render.
- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary.
- For forms initialized from query data, prefer keyed remounts or surface-entry hydration of form/field atoms over an Effect that copies query data into form state.
- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components.
- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow.
## Navigation And Performance
## Effects, Navigation, And Performance
- Use Effects only to synchronize with external systems. Do not use Effects to transform props/state for rendering, handle user actions, copy state, reset state from props, or fetch data.
- For forms initialized from query data, prefer keyed remounts or surface-entry atom hydration over Effects that copy query data into form state.
- Prefer framework data APIs or TanStack Query for data fetching.
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched.
- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree.
- Before using `memo`, move changing state down to the smallest component that uses it. If state must wrap stable content, lift the stable content up and pass it as `children`.
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.

View File

@ -105,6 +105,10 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip
- name: Web dead code check production
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip:production
ts-common-style:
name: TS Common
runs-on: depot-ubuntu-24.04

View File

@ -121,11 +121,6 @@
"count": 3
}
},
"web/__tests__/navigation-utils.test.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/__tests__/plugin-tool-workflow-error.test.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -343,14 +338,6 @@
"count": 4
}
},
"web/app/components/app-sidebar/app-sidebar-dropdown.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -559,14 +546,6 @@
"count": 1
}
},
"web/app/components/app/configuration/config-var/select-type-item/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/configuration/config-var/select-var-type.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -616,17 +595,6 @@
"count": 4
}
},
"web/app/components/app/configuration/config/assistant-type-picker/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/app/configuration/config/automatic/get-automatic-res.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -808,11 +776,6 @@
"count": 1
}
},
"web/app/components/app/configuration/prompt-value-panel/utils.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/app/create-app-dialog/app-list/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -1001,11 +964,6 @@
"count": 1
}
},
"web/app/components/apps/new-app-card.tsx": {
"react/set-state-in-effect": {
"count": 3
}
},
"web/app/components/apps/starred-app-card.tsx": {
"jsx-a11y/no-noninteractive-element-to-interactive-role": {
"count": 1
@ -1622,17 +1580,6 @@
"count": 2
}
},
"web/app/components/base/form/components/field/mixed-variable-text-input/placeholder.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/form/components/field/variable-selector.tsx": {
"no-console": {
"count": 1
@ -1648,22 +1595,12 @@
"count": 1
}
},
"web/app/components/base/form/form-scenarios/base/index.tsx": {
"react/static-components": {
"count": 2
}
},
"web/app/components/base/form/form-scenarios/base/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/base/form/form-scenarios/demo/index.tsx": {
"no-console": {
"count": 2
"count": 1
}
},
"web/app/components/base/form/form-scenarios/input-field/__tests__/field.spec.tsx": {
@ -1684,24 +1621,6 @@
"count": 2
}
},
"web/app/components/base/form/form-scenarios/node-panel/__tests__/field.spec.tsx": {
"react/static-components": {
"count": 2
}
},
"web/app/components/base/form/form-scenarios/node-panel/field.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/form/form-scenarios/node-panel/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/base/form/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 3
@ -1807,7 +1726,7 @@
},
"web/app/components/base/icons/src/vender/line/arrows/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 6
"count": 5
}
},
"web/app/components/base/icons/src/vender/line/communication/index.ts": {
@ -1832,7 +1751,7 @@
},
"web/app/components/base/icons/src/vender/line/files/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 6
"count": 4
}
},
"web/app/components/base/icons/src/vender/line/financeAndECommerce/index.ts": {
@ -1842,7 +1761,7 @@
},
"web/app/components/base/icons/src/vender/line/general/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 11
"count": 10
}
},
"web/app/components/base/icons/src/vender/line/images/index.ts": {
@ -1850,11 +1769,6 @@
"count": 1
}
},
"web/app/components/base/icons/src/vender/line/layout/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 1
}
},
"web/app/components/base/icons/src/vender/line/mediaAndDevices/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@ -1917,7 +1831,7 @@
},
"web/app/components/base/icons/src/vender/solid/education/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 3
"count": 2
}
},
"web/app/components/base/icons/src/vender/solid/files/index.ts": {
@ -1986,17 +1900,6 @@
"count": 3
}
},
"web/app/components/base/image-uploader/audio-preview.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/media-has-caption": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/base/image-uploader/hooks.ts": {
"ts/no-explicit-any": {
"count": 4
@ -2034,17 +1937,6 @@
"count": 2
}
},
"web/app/components/base/image-uploader/video-preview.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/media-has-caption": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/base/inline-delete-confirm/index.stories.tsx": {
"no-console": {
"count": 2
@ -2594,27 +2486,6 @@
"count": 4
}
},
"web/app/components/base/with-input-validation/index.stories.tsx": {
"jsx-a11y/aria-role": {
"count": 7
},
"no-console": {
"count": 1
}
},
"web/app/components/base/with-input-validation/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/billing/header-billing-btn/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/billing/plan/assets/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 4
@ -2892,14 +2763,6 @@
"count": 3
}
},
"web/app/components/datasets/create/step-two/preview-item/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
},
"web/app/components/datasets/create/website/base/crawled-result-item.tsx": {
"jsx-a11y/label-has-associated-control": {
"count": 1
@ -3364,14 +3227,6 @@
"count": 1
}
},
"web/app/components/datasets/list/dataset-card/operation-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/metadata/edit-metadata-batch/add-row.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -3655,19 +3510,6 @@
"count": 2
}
},
"web/app/components/header/account-setting/key-validator/Operate.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 4
},
"jsx-a11y/no-static-element-interactions": {
"count": 4
}
},
"web/app/components/header/account-setting/key-validator/declarations.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx": {
"jsx-a11y/no-autofocus": {
"count": 1
@ -3900,14 +3742,6 @@
"count": 1
}
},
"web/app/components/header/nav/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/main-nav/components/workspace-switcher.tsx": {
"jsx-a11y/no-autofocus": {
"count": 1
@ -4044,11 +3878,6 @@
"count": 2
}
},
"web/app/components/plugins/plugin-auth/utils.ts": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -4212,17 +4041,9 @@
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 5
},
"jsx-a11y/no-static-element-interactions": {
"count": 5
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 7
"count": 6
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx": {
@ -4252,11 +4073,6 @@
"count": 3
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -4297,14 +4113,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/plugins/plugin-page/filter-management/category-filter.tsx": {
"no-restricted-imports": {
"count": 1
@ -4629,11 +4437,6 @@
"count": 1
}
},
"web/app/components/share/utils.ts": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/snippet-list/components/snippet-card.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -4971,11 +4774,6 @@
"count": 4
}
},
"web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/workflow/block-selector/use-sticky-scroll.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -5342,30 +5140,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/next-step/operator.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
@ -5421,14 +5195,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/support-var-input/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -5627,11 +5393,6 @@
"count": 7
}
},
"web/app/components/workflow/nodes/agent/use-single-run-form-params.ts": {
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/workflow/nodes/answer/default.ts": {
"ts/no-explicit-any": {
"count": 1
@ -5675,20 +5436,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/code/dependency-picker.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-autofocus": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/code/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -5784,14 +5531,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/http/components/key-value/bulk-edit/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -6385,11 +6124,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/tool/components/input-var-list.tsx": {
"ts/no-explicit-any": {
"count": 7
}
},
"web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -6487,11 +6221,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts": {
"ts/no-explicit-any": {
"count": 7
}
},
"web/app/components/workflow/nodes/trigger-schedule/default.ts": {
"regexp/no-unused-capturing-group": {
"count": 2
@ -6843,14 +6572,6 @@
"count": 1
}
},
"web/app/components/workflow/run/__tests__/loop-result-panel.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/run/__tests__/special-result-panel.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -6938,14 +6659,6 @@
"count": 2
}
},
"web/app/components/workflow/run/loop-result-panel.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/run/node.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -7022,26 +6735,11 @@
"count": 11
}
},
"web/app/components/workflow/run/utils/format-log/graph-to-log-struct.ts": {
"ts/no-explicit-any": {
"count": 7
}
},
"web/app/components/workflow/run/utils/format-log/index.ts": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/workflow/run/utils/format-log/iteration/index.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/run/utils/format-log/loop/index.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/run/utils/format-log/parallel/index.ts": {
"no-console": {
"count": 4
@ -7091,11 +6789,6 @@
"count": 1
}
},
"web/app/components/workflow/utils/debug.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/utils/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 10
@ -7924,11 +7617,6 @@
"count": 6
}
},
"web/utils/navigation.spec.ts": {
"ts/no-explicit-any": {
"count": 4
}
},
"web/utils/tool-call.spec.ts": {
"ts/no-explicit-any": {
"count": 1

View File

@ -1,88 +0,0 @@
import type { ReactNode } from 'react'
import { useStore } from '@tanstack/react-form'
import { useState } from 'react'
import { useAppForm } from '@/app/components/base/form'
type UseAppFormOptions = Parameters<typeof useAppForm>[0]
type AppFormInstance = ReturnType<typeof useAppForm>
type FormStoryWrapperProps = {
options?: UseAppFormOptions
children: (form: AppFormInstance) => ReactNode
title?: string
subtitle?: string
}
export const FormStoryWrapper = ({
options,
children,
title,
subtitle,
}: FormStoryWrapperProps) => {
const [lastSubmitted, setLastSubmitted] = useState<unknown>(null)
const [submitCount, setSubmitCount] = useState(0)
const form = useAppForm({
...options,
onSubmit: (context) => {
setSubmitCount(count => count + 1)
setLastSubmitted(context.value)
options?.onSubmit?.(context)
},
})
const values = useStore(form.store, state => state.values)
const isSubmitting = useStore(form.store, state => state.isSubmitting)
const canSubmit = useStore(form.store, state => state.canSubmit)
return (
<div className="flex flex-col gap-6 px-6 md:flex-row md:px-10">
<div className="flex-1 space-y-4">
{(title || subtitle) && (
<header className="space-y-1">
{title && <h3 className="text-lg font-semibold text-text-primary">{title}</h3>}
{subtitle && <p className="text-sm text-text-tertiary">{subtitle}</p>}
</header>
)}
{children(form)}
</div>
<aside className="w-full max-w-sm rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary shadow-sm">
<div className="flex items-center justify-between text-[11px] tracking-wide text-text-tertiary uppercase">
<span>Form State</span>
<span>
{submitCount}
{' '}
submit
{submitCount === 1 ? '' : 's'}
</span>
</div>
<dl className="mt-2 space-y-1">
<div className="flex items-center justify-between rounded-md bg-components-button-tertiary-bg px-2 py-1">
<dt className="font-medium text-text-secondary">isSubmitting</dt>
<dd className="font-mono text-[11px] text-text-primary">{String(isSubmitting)}</dd>
</div>
<div className="flex items-center justify-between rounded-md bg-components-button-tertiary-bg px-2 py-1">
<dt className="font-medium text-text-secondary">canSubmit</dt>
<dd className="font-mono text-[11px] text-text-primary">{String(canSubmit)}</dd>
</div>
</dl>
<div className="mt-3 space-y-2">
<div>
<div className="mb-1 font-medium text-text-secondary">Current Values</div>
<pre className="max-h-48 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
{JSON.stringify(values, null, 2)}
</pre>
</div>
<div>
<div className="mb-1 font-medium text-text-secondary">Last Submission</div>
<pre className="max-h-40 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
{lastSubmitted ? JSON.stringify(lastSubmitted, null, 2) : '—'}
</pre>
</div>
</div>
</aside>
</div>
)
}
export type FormStoryRender = (form: AppFormInstance) => ReactNode

View File

@ -1,151 +0,0 @@
import type { SVGProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppDetailNav from '@/app/components/app-sidebar'
const mockSetDetailSidebarMode = vi.fn()
let mockDetailSidebarMode = 'expand'
let mockPathname = '/app/app-1/logs'
let mockSelectedSegment = 'logs'
let mockIsHovering = true
let hotkeyHandler: ((event: { preventDefault: () => void }) => void) | null = null
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/main-nav/storage', () => ({
useDetailSidebarMode: () => [mockDetailSidebarMode, mockSetDetailSidebarMode],
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
useSelectedLayoutSegment: () => mockSelectedSegment,
}))
vi.mock('@/next/link', () => ({
default: ({
href,
children,
className,
title,
}: {
href: string
children?: React.ReactNode
className?: string
title?: string
}) => (
<a href={href} className={className} title={title}>
{children}
</a>
),
}))
vi.mock('ahooks', () => ({
useHover: () => mockIsHovering,
}))
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: (_hotkey: string, handler: (event: { preventDefault: () => void }) => void) => {
hotkeyHandler = handler
},
}
})
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
desktop: 'desktop',
},
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip'))
vi.mock('@/app/components/app-sidebar/app-info', () => ({
default: ({
expand,
onlyShowDetail,
openState,
}: {
expand: boolean
onlyShowDetail?: boolean
openState?: boolean
}) => (
<div
data-testid={onlyShowDetail ? 'app-info-detail' : 'app-info'}
data-expand={expand}
data-open={openState}
/>
),
}))
const MockIcon = (props: SVGProps<SVGSVGElement>) => <svg {...props} />
const navigation = [
{ name: 'Overview', href: '/app/app-1/overview', icon: MockIcon, selectedIcon: MockIcon },
{ name: 'Logs', href: '/app/app-1/logs', icon: MockIcon, selectedIcon: MockIcon },
]
describe('App Sidebar Shell Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockDetailSidebarMode = 'expand'
mockPathname = '/app/app-1/logs'
mockSelectedSegment = 'logs'
mockIsHovering = true
hotkeyHandler = null
})
it('renders the expanded sidebar, marks the active nav item, and toggles collapse by click and shortcut', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'true')
const logsLink = screen.getByRole('link', { name: /Logs/i })
expect(logsLink.className).toContain('bg-components-menu-item-bg-active')
fireEvent.click(screen.getByRole('button'))
expect(mockSetDetailSidebarMode).toHaveBeenCalledWith('collapse')
const preventDefault = vi.fn()
hotkeyHandler?.({ preventDefault })
expect(preventDefault).toHaveBeenCalled()
expect(mockSetDetailSidebarMode).toHaveBeenCalledWith('collapse')
})
it('keeps the normal sidebar on workflow routes', () => {
mockPathname = '/app/app-1/workflow'
mockSelectedSegment = 'workflow'
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('app-info')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Overview/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Logs/i })).toBeInTheDocument()
})
})

View File

@ -1,65 +0,0 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DemoForm from '@/app/components/base/form/form-scenarios/demo'
describe('Base Form Demo Flow', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
beforeEach(() => {
vi.clearAllMocks()
})
it('reveals contact fields and submits the composed form values through the shared form actions', async () => {
const user = userEvent.setup()
render(<DemoForm />)
expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: /^name$/i }), 'Alice')
await user.type(screen.getByRole('textbox', { name: /^surname$/i }), 'Smith')
await user.click(screen.getByText(/i accept the terms and conditions/i))
expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: /^email$/i }), 'alice@example.com')
const preferredMethodLabel = screen.getByText('Preferred Contact Method')
const preferredMethodField = preferredMethodLabel.parentElement?.parentElement
expect(preferredMethodField).toBeTruthy()
await user.click(within(preferredMethodField as HTMLElement).getByText('Email'))
await user.click(screen.getByText('Whatsapp'))
const submitButton = screen.getByRole('button', { name: /operation\.submit/i })
expect(submitButton).toBeEnabled()
await user.click(submitButton)
await waitFor(() => {
expect(consoleLogSpy).toHaveBeenCalledWith('Form submitted:', expect.objectContaining({
name: 'Alice',
surname: 'Smith',
isAcceptingTerms: true,
contact: expect.objectContaining({
email: 'alice@example.com',
preferredContactMethod: 'whatsapp',
}),
}))
})
})
it('removes the nested contact section again when the name field is cleared', async () => {
const user = userEvent.setup()
render(<DemoForm />)
const nameInput = screen.getByRole('textbox', { name: /^name$/i })
await user.type(nameInput, 'Alice')
expect(await screen.findByRole('heading', { name: /contacts/i })).toBeInTheDocument()
await user.clear(nameInput)
await waitFor(() => {
expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument()
})
})
})

View File

@ -7,7 +7,6 @@ import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import Billing from '@/app/components/billing/billing-page'
import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config'
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
import PlanComp from '@/app/components/billing/plan'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import PriorityLabel from '@/app/components/billing/priority-label'
@ -696,83 +695,7 @@ describe('Capacity Full Components Integration', () => {
})
// ═══════════════════════════════════════════════════════════════════════════
// 5. Header Billing Button Integration
// Tests HeaderBillingBtn behavior for different plan states
// ═══════════════════════════════════════════════════════════════════════════
describe('Header Billing Button Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
it('should render UpgradeBtn (premium badge) for sandbox plan', () => {
setupProviderContext({ type: Plan.sandbox })
render(<HeaderBillingBtn />)
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render "pro" badge for professional plan', () => {
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn />)
expect(screen.getByText('pro')).toBeInTheDocument()
expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument()
})
it('should render "team" badge for team plan', () => {
setupProviderContext({ type: Plan.team })
render(<HeaderBillingBtn />)
expect(screen.getByText('team')).toBeInTheDocument()
})
it('should return null when billing is disabled', () => {
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
const { container } = render(<HeaderBillingBtn />)
expect(container.innerHTML).toBe('')
})
it('should return null when plan is not fetched yet', () => {
setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false })
const { container } = render(<HeaderBillingBtn />)
expect(container.innerHTML).toBe('')
})
it('should call onClick when clicking pro/team badge in non-display-only mode', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn onClick={onClick} />)
await user.click(screen.getByText('pro'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should not call onClick when isDisplayOnly is true', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
await user.click(screen.getByText('pro'))
expect(onClick).not.toHaveBeenCalled()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 6. PriorityLabel Integration
// 5. PriorityLabel Integration
// Tests priority badge display for different plan types
// ═══════════════════════════════════════════════════════════════════════════
describe('PriorityLabel Integration', () => {

View File

@ -1,168 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Nav from '@/app/components/header/nav'
import { AppModeEnum } from '@/types/app'
const mockPush = vi.fn()
const mockSetAppDetail = vi.fn()
const mockOnCreate = vi.fn()
const mockOnLoadMore = vi.fn()
let mockSelectedSegment = 'app'
let mockIsCurrentWorkspaceEditor = true
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: () => mockSelectedSegment,
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/next/link', () => ({
default: ({
href,
children,
}: {
href: string
children?: React.ReactNode
}) => <a href={href}>{children}</a>,
}))
vi.mock('@/app/components/app/store', () => ({
useStore: () => mockSetAppDetail,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
workspacePermissionKeys: mockIsCurrentWorkspaceEditor ? ['app.create_and_management'] : [],
}),
}))
const navigationItems = [
{
id: 'app-1',
name: 'Alpha',
link: '/app/app-1/configuration',
icon_type: 'emoji' as const,
icon: '🤖',
icon_background: '#FFEAD5',
icon_url: null,
mode: AppModeEnum.CHAT,
},
{
id: 'app-2',
name: 'Bravo',
link: '/app/app-2/workflow',
icon_type: 'emoji' as const,
icon: '⚙️',
icon_background: '#E0F2FE',
icon_url: null,
mode: AppModeEnum.WORKFLOW,
},
]
const curNav = {
id: 'app-1',
name: 'Alpha',
icon_type: 'emoji' as const,
icon: '🤖',
icon_background: '#FFEAD5',
icon_url: null,
mode: AppModeEnum.CHAT,
}
const renderNav = (nav = curNav) => {
return render(
<Nav
isApp
icon={<span data-testid="nav-icon">icon</span>}
activeIcon={<span data-testid="nav-icon-active">active-icon</span>}
text="menus.apps"
activeSegment={['apps', 'app']}
link="/apps"
curNav={nav}
navigationItems={navigationItems}
createText="menus.newApp"
onCreate={mockOnCreate}
onLoadMore={mockOnLoadMore}
isLoadingMore={false}
/>,
)
}
describe('Header Nav Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSelectedSegment = 'app'
mockIsCurrentWorkspaceEditor = true
})
it('switches to another app from the selector and clears stale app detail first', async () => {
renderNav()
fireEvent.click(screen.getByRole('button', { name: /Alpha/i }))
fireEvent.click(await screen.findByText('Bravo'))
expect(mockSetAppDetail).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/app/app-2/workflow')
})
it('opens the nested create menu and emits all app creation branches', async () => {
const user = userEvent.setup()
const clickCreateBranch = async (optionName: string) => {
const { unmount } = renderNav()
await user.click(screen.getByRole('button', { name: /Alpha/i }))
await user.hover(await screen.findByRole('menuitem', { name: /menus\.newApp/i }))
fireEvent.click(await screen.findByRole('menuitem', { name: optionName }))
unmount()
}
await clickCreateBranch('newApp.startFromBlank')
await clickCreateBranch('newApp.startFromTemplate')
await clickCreateBranch('importDSL')
expect(mockOnCreate).toHaveBeenNthCalledWith(1, 'blank')
expect(mockOnCreate).toHaveBeenNthCalledWith(2, 'template')
expect(mockOnCreate).toHaveBeenNthCalledWith(3, 'dsl')
expect(mockOnCreate).toHaveBeenCalledTimes(3)
})
it('keeps the current nav label in sync with prop updates', async () => {
const { rerender } = renderNav()
expect(screen.getByRole('button', { name: /Alpha/i })).toBeInTheDocument()
rerender(
<Nav
isApp
icon={<span data-testid="nav-icon">icon</span>}
activeIcon={<span data-testid="nav-icon-active">active-icon</span>}
text="menus.apps"
activeSegment={['apps', 'app']}
link="/apps"
curNav={{
...curNav,
name: 'Alpha Renamed',
}}
navigationItems={navigationItems}
createText="menus.newApp"
onCreate={mockOnCreate}
onLoadMore={mockOnLoadMore}
isLoadingMore={false}
/>,
)
await waitFor(() => {
expect(screen.getByRole('button', { name: /Alpha Renamed/i })).toBeInTheDocument()
})
})
})

View File

@ -1,401 +0,0 @@
/**
* Navigation Utilities Test
*
* Tests for the navigation utility functions to ensure they handle
* query parameter preservation correctly across different scenarios.
*/
import {
createBackNavigation,
createNavigationPath,
createNavigationPathWithParams,
datasetNavigation,
extractQueryParams,
mergeQueryParams,
} from '@/utils/navigation'
// Mock router for testing
const mockPush = vi.fn()
const mockRouter = { push: mockPush }
describe('Navigation Utilities', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('createNavigationPath', () => {
it('preserves query parameters by default', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10&keyword=test' },
writable: true,
})
const path = createNavigationPath('/datasets/123/documents')
expect(path).toBe('/datasets/123/documents?page=3&limit=10&keyword=test')
})
it('returns clean path when preserveParams is false', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10' },
writable: true,
})
const path = createNavigationPath('/datasets/123/documents', false)
expect(path).toBe('/datasets/123/documents')
})
it('handles empty query parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '' },
writable: true,
})
const path = createNavigationPath('/datasets/123/documents')
expect(path).toBe('/datasets/123/documents')
})
it('handles errors gracefully', () => {
// Mock window.location to throw an error
Object.defineProperty(window, 'location', {
get: () => {
throw new Error('Location access denied')
},
configurable: true,
})
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* noop */ })
const path = createNavigationPath('/datasets/123/documents')
expect(path).toBe('/datasets/123/documents')
expect(consoleSpy).toHaveBeenCalledWith('Failed to preserve query parameters:', expect.any(Error))
consoleSpy.mockRestore()
})
})
describe('createBackNavigation', () => {
it('creates function that navigates with preserved params', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=2&limit=25' },
writable: true,
})
const backFn = createBackNavigation(mockRouter, '/datasets/123/documents')
backFn()
expect(mockPush).toHaveBeenCalledWith('/datasets/123/documents?page=2&limit=25')
})
it('creates function that navigates without params when specified', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=2&limit=25' },
writable: true,
})
const backFn = createBackNavigation(mockRouter, '/datasets/123/documents', false)
backFn()
expect(mockPush).toHaveBeenCalledWith('/datasets/123/documents')
})
})
describe('extractQueryParams', () => {
it('extracts specified parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10&keyword=test&other=value' },
writable: true,
})
const params = extractQueryParams(['page', 'limit', 'keyword'])
expect(params).toEqual({
page: '3',
limit: '10',
keyword: 'test',
})
})
it('handles missing parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3' },
writable: true,
})
const params = extractQueryParams(['page', 'limit', 'missing'])
expect(params).toEqual({
page: '3',
})
})
it('handles errors gracefully', () => {
Object.defineProperty(window, 'location', {
get: () => {
throw new Error('Location access denied')
},
configurable: true,
})
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* noop */ })
const params = extractQueryParams(['page', 'limit'])
expect(params).toEqual({})
expect(consoleSpy).toHaveBeenCalledWith('Failed to extract query parameters:', expect.any(Error))
consoleSpy.mockRestore()
})
})
describe('createNavigationPathWithParams', () => {
it('creates path with specified parameters', () => {
const path = createNavigationPathWithParams('/datasets/123/documents', {
page: 1,
limit: 25,
keyword: 'search term',
})
expect(path).toBe('/datasets/123/documents?page=1&limit=25&keyword=search+term')
})
it('filters out empty values', () => {
const path = createNavigationPathWithParams('/datasets/123/documents', {
page: 1,
limit: '',
keyword: 'test',
filter: '',
})
expect(path).toBe('/datasets/123/documents?page=1&keyword=test')
})
it('handles errors gracefully', () => {
// Mock URLSearchParams to throw an error
const originalURLSearchParams = globalThis.URLSearchParams
globalThis.URLSearchParams = vi.fn(() => {
throw new Error('URLSearchParams error')
}) as any
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* noop */ })
const path = createNavigationPathWithParams('/datasets/123/documents', { page: 1 })
expect(path).toBe('/datasets/123/documents')
expect(consoleSpy).toHaveBeenCalledWith('Failed to create navigation path with params:', expect.any(Error))
consoleSpy.mockRestore()
globalThis.URLSearchParams = originalURLSearchParams
})
})
describe('mergeQueryParams', () => {
it('merges new params with existing ones', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10' },
writable: true,
})
const merged = mergeQueryParams({ keyword: 'test', page: '1' })
const result = merged.toString()
expect(result).toContain('page=1') // overridden
expect(result).toContain('limit=10') // preserved
expect(result).toContain('keyword=test') // added
})
it('removes parameters when value is null', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10&keyword=test' },
writable: true,
})
const merged = mergeQueryParams({ keyword: null, filter: 'active' })
const result = merged.toString()
expect(result).toContain('page=3')
expect(result).toContain('limit=10')
expect(result).not.toContain('keyword')
expect(result).toContain('filter=active')
})
it('creates fresh params when preserveExisting is false', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=3&limit=10' },
writable: true,
})
const merged = mergeQueryParams({ keyword: 'test' }, false)
const result = merged.toString()
expect(result).toBe('keyword=test')
})
})
describe('datasetNavigation', () => {
it('backToDocuments creates correct navigation function', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=2&limit=25' },
writable: true,
})
const backFn = datasetNavigation.backToDocuments(mockRouter, 'dataset-123')
backFn()
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=2&limit=25')
})
it('toDocumentDetail creates correct navigation function', () => {
const detailFn = datasetNavigation.toDocumentDetail(mockRouter, 'dataset-123', 'doc-456')
detailFn()
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents/doc-456')
})
it('toDocumentSettings creates correct navigation function', () => {
const settingsFn = datasetNavigation.toDocumentSettings(mockRouter, 'dataset-123', 'doc-456')
settingsFn()
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents/doc-456/settings')
})
})
describe('Real-world Integration Scenarios', () => {
it('complete user workflow: list -> detail -> back', () => {
// User starts on page 3 with search
Object.defineProperty(window, 'location', {
value: { search: '?page=3&keyword=API&limit=25' },
writable: true,
})
// Create back navigation function (as would be done in detail component)
const backToDocuments = datasetNavigation.backToDocuments(mockRouter, 'main-dataset')
// User clicks back
backToDocuments()
// Should return to exact same list state
expect(mockPush).toHaveBeenCalledWith('/datasets/main-dataset/documents?page=3&keyword=API&limit=25')
})
it('user applies filters then views document', () => {
// Complex filter state
Object.defineProperty(window, 'location', {
value: { search: '?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc' },
writable: true,
})
const backFn = createBackNavigation(mockRouter, '/datasets/filtered-set/documents')
backFn()
expect(mockPush).toHaveBeenCalledWith('/datasets/filtered-set/documents?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc')
})
})
describe('Edge Cases and Error Handling', () => {
it('handles special characters in query parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?keyword=hello%20world&filter=type%3Apdf&tag=%E4%B8%AD%E6%96%87' },
writable: true,
})
const path = createNavigationPath('/datasets/123/documents')
expect(path).toContain('hello+world')
expect(path).toContain('type%3Apdf')
expect(path).toContain('%E4%B8%AD%E6%96%87')
})
it('handles duplicate query parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?tag=tag1&tag=tag2&tag=tag3' },
writable: true,
})
const params = extractQueryParams(['tag'])
// URLSearchParams.get() returns the first value
expect(params.tag).toBe('tag1')
})
it('handles very long query strings', () => {
const longValue = 'a'.repeat(1000)
Object.defineProperty(window, 'location', {
value: { search: `?data=${longValue}` },
writable: true,
})
const path = createNavigationPath('/datasets/123/documents')
expect(path).toContain(longValue)
expect(path.length).toBeGreaterThan(1000)
})
it('handles empty string values in query parameters', () => {
const path = createNavigationPathWithParams('/datasets/123/documents', {
page: 1,
keyword: '',
filter: '',
sort: 'name',
})
expect(path).toBe('/datasets/123/documents?page=1&sort=name')
expect(path).not.toContain('keyword=')
expect(path).not.toContain('filter=')
})
it('handles null and undefined values in mergeQueryParams', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=1&limit=10&keyword=test' },
writable: true,
})
const merged = mergeQueryParams({
keyword: null,
filter: undefined,
sort: 'name',
})
const result = merged.toString()
expect(result).toContain('page=1')
expect(result).toContain('limit=10')
expect(result).not.toContain('keyword')
expect(result).toContain('sort=name')
})
it('handles navigation with hash fragments', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=1', hash: '#section-2' },
writable: true,
})
const path = createNavigationPath('/datasets/123/documents')
// Should preserve query params but not hash
expect(path).toBe('/datasets/123/documents?page=1')
})
it('handles malformed query strings gracefully', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=1&invalid&limit=10&=value&key=' },
writable: true,
})
const params = extractQueryParams(['page', 'limit', 'invalid', 'key'])
expect(params.page).toBe('1')
expect(params.limit).toBe('10')
// Malformed params should be handled by URLSearchParams
expect(params.invalid).toBe('') // for `&invalid`
expect(params.key).toBe('') // for `&key=`
})
})
describe('Performance Tests', () => {
it('handles large number of query parameters efficiently', () => {
const manyParams = Array.from({ length: 50 }, (_, i) => `param${i}=value${i}`).join('&')
Object.defineProperty(window, 'location', {
value: { search: `?${manyParams}` },
writable: true,
})
const startTime = Date.now()
const path = createNavigationPath('/datasets/123/documents')
const endTime = Date.now()
expect(endTime - startTime).toBeLessThan(50) // Should be fast
expect(path).toContain('param0=value0')
expect(path).toContain('param49=value49')
})
})
})

View File

@ -1,159 +0,0 @@
/**
* Integration Test: Plugin Data Utilities
*
* Tests the integration between plugin utility functions, including
* tag/category validation, form schema transformation, and
* credential data processing. Verifies that these utilities work
* correctly together in processing plugin metadata.
*/
import { describe, expect, it } from 'vitest'
import { transformFormSchemasSecretInput } from '@/app/components/plugins/plugin-auth/utils'
import { getValidCategoryKeys, getValidTagKeys } from '@/app/components/plugins/utils'
type TagInput = Parameters<typeof getValidTagKeys>[0]
describe('Plugin Data Utilities Integration', () => {
describe('Tag and Category Validation Pipeline', () => {
it('validates tags and categories in a metadata processing flow', () => {
const pluginMetadata = {
tags: ['search', 'productivity', 'invalid-tag', 'media-generate'],
category: 'tool',
}
const validTags = getValidTagKeys(pluginMetadata.tags as TagInput)
expect(validTags.length).toBeGreaterThan(0)
expect(validTags.length).toBeLessThanOrEqual(pluginMetadata.tags.length)
const validCategory = getValidCategoryKeys(pluginMetadata.category)
expect(validCategory).toBeDefined()
})
it('handles completely invalid metadata gracefully', () => {
const invalidMetadata = {
tags: ['nonexistent-1', 'nonexistent-2'],
category: 'nonexistent-category',
}
const validTags = getValidTagKeys(invalidMetadata.tags as TagInput)
expect(validTags).toHaveLength(0)
const validCategory = getValidCategoryKeys(invalidMetadata.category)
expect(validCategory).toBeUndefined()
})
it('handles undefined and empty inputs', () => {
expect(getValidTagKeys([] as TagInput)).toHaveLength(0)
expect(getValidCategoryKeys(undefined)).toBeUndefined()
expect(getValidCategoryKeys('')).toBeUndefined()
})
})
describe('Credential Secret Masking Pipeline', () => {
it('masks secrets when displaying credential form data', () => {
const credentialValues = {
api_key: 'sk-abc123456789',
api_endpoint: 'https://api.example.com',
secret_token: 'secret-token-value',
description: 'My credential set',
}
const secretFields = ['api_key', 'secret_token']
const displayValues = transformFormSchemasSecretInput(secretFields, credentialValues)
expect(displayValues.api_key).toBe('[__HIDDEN__]')
expect(displayValues.secret_token).toBe('[__HIDDEN__]')
expect(displayValues.api_endpoint).toBe('https://api.example.com')
expect(displayValues.description).toBe('My credential set')
})
it('preserves original values when no secret fields', () => {
const values = {
name: 'test',
endpoint: 'https://api.example.com',
}
const result = transformFormSchemasSecretInput([], values)
expect(result).toEqual(values)
})
it('handles falsy secret values without masking', () => {
const values = {
api_key: '',
secret: null as unknown as string,
other: 'visible',
}
const result = transformFormSchemasSecretInput(['api_key', 'secret'], values)
expect(result.api_key).toBe('')
expect(result.secret).toBeNull()
expect(result.other).toBe('visible')
})
it('does not mutate the original values object', () => {
const original = {
api_key: 'my-secret-key',
name: 'test',
}
const originalCopy = { ...original }
transformFormSchemasSecretInput(['api_key'], original)
expect(original).toEqual(originalCopy)
})
})
describe('Combined Plugin Metadata Validation', () => {
it('processes a complete plugin entry with tags and credentials', () => {
const pluginEntry = {
name: 'test-plugin',
category: 'tool',
tags: ['search', 'invalid-tag'],
credentials: {
api_key: 'sk-test-key-123',
base_url: 'https://api.test.com',
},
secretFields: ['api_key'],
}
const validCategory = getValidCategoryKeys(pluginEntry.category)
expect(validCategory).toBe('tool')
const validTags = getValidTagKeys(pluginEntry.tags as TagInput)
expect(validTags).toContain('search')
const displayCredentials = transformFormSchemasSecretInput(
pluginEntry.secretFields,
pluginEntry.credentials,
)
expect(displayCredentials.api_key).toBe('[__HIDDEN__]')
expect(displayCredentials.base_url).toBe('https://api.test.com')
expect(pluginEntry.credentials.api_key).toBe('sk-test-key-123')
})
it('handles multiple plugins in batch processing', () => {
const plugins = [
{ tags: ['search', 'productivity'], category: 'tool' },
{ tags: ['image', 'design'], category: 'model' },
{ tags: ['invalid'], category: 'extension' },
]
const results = plugins.map(p => ({
validTags: getValidTagKeys(p.tags as TagInput),
validCategory: getValidCategoryKeys(p.category),
}))
expect(results[0]!.validTags.length).toBeGreaterThan(0)
expect(results[0]!.validCategory).toBe('tool')
expect(results[1]!.validTags).toContain('image')
expect(results[1]!.validTags).toContain('design')
expect(results[1]!.validCategory).toBe('model')
expect(results[2]!.validTags).toHaveLength(0)
expect(results[2]!.validCategory).toBe('extension')
})
})
})

View File

@ -1,120 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useStore } from '@/app/components/plugins/plugin-page/filter-management/store'
describe('Plugin Page Filter Management Integration', () => {
beforeEach(() => {
const { result } = renderHook(() => useStore())
act(() => {
result.current.setTagList([])
result.current.setCategoryList([])
result.current.setShowTagManagementModal(false)
result.current.setShowCategoryManagementModal(false)
})
})
describe('tag and category filter lifecycle', () => {
it('should manage full tag lifecycle: add -> update -> clear', () => {
const { result } = renderHook(() => useStore())
const initialTags = [
{ name: 'search', label: { en_US: 'Search' } },
{ name: 'productivity', label: { en_US: 'Productivity' } },
]
act(() => {
result.current.setTagList(initialTags as never[])
})
expect(result.current.tagList).toHaveLength(2)
const updatedTags = [
...initialTags,
{ name: 'image', label: { en_US: 'Image' } },
]
act(() => {
result.current.setTagList(updatedTags as never[])
})
expect(result.current.tagList).toHaveLength(3)
act(() => {
result.current.setTagList([])
})
expect(result.current.tagList).toHaveLength(0)
})
it('should manage full category lifecycle: add -> update -> clear', () => {
const { result } = renderHook(() => useStore())
const categories = [
{ name: 'tool', label: { en_US: 'Tool' } },
{ name: 'model', label: { en_US: 'Model' } },
]
act(() => {
result.current.setCategoryList(categories as never[])
})
expect(result.current.categoryList).toHaveLength(2)
act(() => {
result.current.setCategoryList([])
})
expect(result.current.categoryList).toHaveLength(0)
})
})
describe('modal state management', () => {
it('should manage tag management modal independently', () => {
const { result } = renderHook(() => useStore())
act(() => {
result.current.setShowTagManagementModal(true)
})
expect(result.current.showTagManagementModal).toBe(true)
expect(result.current.showCategoryManagementModal).toBe(false)
act(() => {
result.current.setShowTagManagementModal(false)
})
expect(result.current.showTagManagementModal).toBe(false)
})
it('should manage category management modal independently', () => {
const { result } = renderHook(() => useStore())
act(() => {
result.current.setShowCategoryManagementModal(true)
})
expect(result.current.showCategoryManagementModal).toBe(true)
expect(result.current.showTagManagementModal).toBe(false)
})
it('should support both modals open simultaneously', () => {
const { result } = renderHook(() => useStore())
act(() => {
result.current.setShowTagManagementModal(true)
result.current.setShowCategoryManagementModal(true)
})
expect(result.current.showTagManagementModal).toBe(true)
expect(result.current.showCategoryManagementModal).toBe(true)
})
})
describe('state persistence across renders', () => {
it('should maintain filter state when re-rendered', () => {
const { result, rerender } = renderHook(() => useStore())
act(() => {
result.current.setTagList([{ name: 'search' }] as never[])
result.current.setCategoryList([{ name: 'tool' }] as never[])
})
rerender()
expect(result.current.tagList).toHaveLength(1)
expect(result.current.categoryList).toHaveLength(1)
})
})
})

View File

@ -1,14 +1,13 @@
/**
* XSS Prevention Test Suite
*
* This test verifies that the XSS vulnerabilities in block-input and support-var-input
* components have been properly fixed by replacing dangerouslySetInnerHTML with safe React rendering.
* This test verifies that the XSS vulnerability in block-input has been properly
* fixed by replacing dangerouslySetInnerHTML with safe React rendering.
*/
import { cleanup, render } from '@testing-library/react'
import * as React from 'react'
import BlockInput from '../app/components/base/block-input'
import SupportVarInput from '../app/components/workflow/nodes/_base/components/support-var-input'
// Mock styles
vi.mock('../app/components/app/configuration/base/var-highlight/style.module.css', () => ({
@ -17,7 +16,7 @@ vi.mock('../app/components/app/configuration/base/var-highlight/style.module.css
},
}))
describe('XSS Prevention - Block Input and Support Var Input Security', () => {
describe('XSS Prevention - Block Input Security', () => {
afterEach(() => {
cleanup()
})
@ -44,22 +43,6 @@ describe('XSS Prevention - Block Input and Support Var Input Security', () => {
})
})
describe('SupportVarInput Component Security', () => {
it('should safely render malicious variable names without executing scripts', () => {
const testInput = 'test@evil.com{{<img src=x onerror=alert(1)>}}'
const { container } = render(<SupportVarInput value={testInput} readonly={true} />)
const scriptElements = container.querySelectorAll('script')
const imgElements = container.querySelectorAll('img')
expect(scriptElements).toHaveLength(0)
expect(imgElements).toHaveLength(0)
const textContent = container.textContent
expect(textContent).toContain('<img')
})
})
describe('React Automatic Escaping Verification', () => {
it('should confirm React automatic escaping works correctly', () => {
const TestComponent = () => <span>{'<script>alert("xss")</script>'}</span>

View File

@ -1,194 +0,0 @@
import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppSidebarDropdown from '../app-sidebar-dropdown'
let mockAppDetail: (App & Partial<AppSSO>) | undefined
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
appDetail: mockAppDetail,
}),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context)
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-trigger"
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="dropdown-content">{children}</div>,
}
})
vi.mock('../../base/app-icon', () => ({
default: ({ size, icon }: { size: string, icon: string }) => (
<div data-testid="app-icon" data-size={size} data-icon={icon} />
),
}))
vi.mock('../../base/divider', () => ({
default: () => <hr data-testid="divider" />,
}))
vi.mock('../app-info', () => ({
default: ({ expand, onlyShowDetail, openState }: {
expand: boolean
onlyShowDetail?: boolean
openState?: boolean
}) => (
<div data-testid="app-info" data-expand={expand} data-only-detail={onlyShowDetail} data-open={openState} />
),
}))
vi.mock('../nav-link', () => ({
default: ({ name, href, mode }: { name: string, href: string, mode?: string }) => (
<a data-testid={`nav-link-${name}`} href={href} data-mode={mode}>{name}</a>
),
}))
const MockIcon = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
const createAppDetail = (overrides: Partial<App> = {}): App & Partial<AppSSO> => ({
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
description: '',
use_icon_as_answer_icon: false,
...overrides,
} as App & Partial<AppSSO>)
const navigation = [
{ name: 'Overview', href: '/overview', icon: MockIcon, selectedIcon: MockIcon },
{ name: 'Logs', href: '/logs', icon: MockIcon, selectedIcon: MockIcon },
]
describe('AppSidebarDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetail = createAppDetail()
})
it('should return null when appDetail is not available', () => {
mockAppDetail = undefined
const { container } = render(<AppSidebarDropdown navigation={navigation} />)
expect(container.innerHTML).toBe('')
})
it('should render trigger with app icon', () => {
render(<AppSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const smallIcon = icons.find(i => i.getAttribute('data-size') === 'small')
expect(smallIcon).toBeInTheDocument()
})
it('should render navigation links', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('nav-link-Overview')).toBeInTheDocument()
expect(screen.getByTestId('nav-link-Logs')).toBeInTheDocument()
})
it('should display app name', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should display app mode label', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
})
it('should display mode labels for different modes', () => {
mockAppDetail = createAppDetail({ mode: AppModeEnum.ADVANCED_CHAT })
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
})
it('should render AppInfo component for detail expand', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('app-info')).toBeInTheDocument()
expect(screen.getByTestId('app-info')).toHaveAttribute('data-only-detail', 'true')
})
it('should toggle portal open state when trigger is clicked', async () => {
const user = userEvent.setup()
render(<AppSidebarDropdown navigation={navigation} />)
const trigger = screen.getByTestId('dropdown-trigger')
await user.click(trigger)
const dropdown = screen.getByTestId('dropdown-menu')
expect(dropdown).toHaveAttribute('data-open', 'true')
})
it('should render divider between app info and navigation', () => {
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('divider')).toBeInTheDocument()
})
it('should render large app icon in dropdown content', () => {
render(<AppSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const largeIcon = icons.find(icon => icon.getAttribute('data-size') === 'large')
expect(largeIcon).toBeInTheDocument()
})
it('should set detailExpand when clicking app info area', async () => {
const user = userEvent.setup()
render(<AppSidebarDropdown navigation={navigation} />)
const appName = screen.getByText('Test App')
await user.click(appName)
expect(screen.getByTestId('app-info')).toHaveAttribute('data-open', 'true')
})
it('should display workflow mode label', () => {
mockAppDetail = createAppDetail({ mode: AppModeEnum.WORKFLOW })
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
})
it('should display agent mode label', () => {
mockAppDetail = createAppDetail({ mode: AppModeEnum.AGENT_CHAT })
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
})
it('should display completion mode label', () => {
mockAppDetail = createAppDetail({ mode: AppModeEnum.COMPLETION })
render(<AppSidebarDropdown navigation={navigation} />)
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
})

View File

@ -1,216 +0,0 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import DatasetSidebarDropdown from '../dataset-sidebar-dropdown'
let mockDataset: DataSet
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet }) => unknown) =>
selector({ dataset: mockDataset }),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetRelatedApps: () => ({ data: [] }),
}))
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'method-text',
}),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context)
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-trigger"
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="dropdown-content">{children}</div>,
}
})
vi.mock('../../base/app-icon', () => ({
default: ({ size, icon }: { size: string, icon: string }) => (
<div data-testid="app-icon" data-size={size} data-icon={icon} />
),
}))
vi.mock('../../base/divider', () => ({
default: () => <hr data-testid="divider" />,
}))
vi.mock('../../base/effect', () => ({
default: ({ className }: { className?: string }) => <div data-testid="effect" className={className} />,
}))
vi.mock('../../datasets/extra-info', () => ({
default: ({ expand, documentCount }: {
relatedApps?: unknown[]
expand: boolean
documentCount: number
}) => (
<div data-testid="extra-info" data-expand={expand} data-doc-count={documentCount} />
),
}))
vi.mock('../dataset-info/dropdown', () => ({
default: ({ expand }: { expand: boolean }) => (
<div data-testid="dataset-dropdown" data-expand={expand} />
),
}))
vi.mock('../nav-link', () => ({
default: ({ name, href, mode, disabled }: { name: string, href: string, mode?: string, disabled?: boolean }) => (
<a data-testid={`nav-link-${name}`} href={href} data-mode={mode} data-disabled={disabled}>{name}</a>
),
}))
const MockIcon = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'A test dataset',
provider: 'internal',
icon_info: {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
},
doc_form: 'text_model' as DataSet['doc_form'],
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
document_count: 10,
runtime_mode: 'general',
retrieval_model_dict: {
search_method: 'semantic_search' as DataSet['retrieval_model_dict']['search_method'],
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
...overrides,
} as DataSet)
const navigation = [
{ name: 'Documents', href: '/documents', icon: MockIcon, selectedIcon: MockIcon },
{ name: 'Settings', href: '/settings', icon: MockIcon, selectedIcon: MockIcon, disabled: true },
]
describe('DatasetSidebarDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataset = createDataset()
})
it('should render trigger with dataset icon', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const smallIcon = icons.find(i => i.getAttribute('data-size') === 'small')
expect(smallIcon).toBeInTheDocument()
expect(smallIcon).toHaveAttribute('data-icon', '📙')
})
it('should display dataset name in dropdown content', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should display dataset description', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByText('A test dataset')).toBeInTheDocument()
})
it('should not display description when empty', () => {
mockDataset = createDataset({ description: '' })
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.queryByText('A test dataset')).not.toBeInTheDocument()
})
it('should render navigation links', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('nav-link-Documents')).toBeInTheDocument()
expect(screen.getByTestId('nav-link-Settings')).toBeInTheDocument()
})
it('should render ExtraInfo', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
const extraInfo = screen.getByTestId('extra-info')
expect(extraInfo).toHaveAttribute('data-expand', 'true')
expect(extraInfo).toHaveAttribute('data-doc-count', '10')
})
it('should render Effect component', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('effect')).toBeInTheDocument()
})
it('should render Dropdown component with expand=true', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('dataset-dropdown')).toHaveAttribute('data-expand', 'true')
})
it('should show external tag for external provider', () => {
mockDataset = createDataset({ provider: 'external' })
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
})
it('should use fallback icon info when icon_info is missing', () => {
mockDataset = createDataset({ icon_info: undefined as unknown as DataSet['icon_info'] })
render(<DatasetSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const fallbackIcon = icons.find(i => i.getAttribute('data-icon') === '📙')
expect(fallbackIcon).toBeInTheDocument()
})
it('should toggle dropdown open state on trigger click', async () => {
const user = userEvent.setup()
render(<DatasetSidebarDropdown navigation={navigation} />)
const trigger = screen.getByTestId('dropdown-trigger')
await user.click(trigger)
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-open', 'true')
})
it('should render divider', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
expect(screen.getByTestId('divider')).toBeInTheDocument()
})
it('should render medium app icon in content area', () => {
render(<DatasetSidebarDropdown navigation={navigation} />)
const icons = screen.getAllByTestId('app-icon')
const mediumIcon = icons.find(i => i.getAttribute('data-size') === 'medium')
expect(mediumIcon).toBeInTheDocument()
})
})

View File

@ -1,217 +0,0 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import AppDetailNav from '..'
let mockDetailSidebarMode = 'expand'
const mockSetDetailSidebarMode = vi.fn()
vi.mock('@/app/components/main-nav/storage', () => ({
useDetailSidebarMode: () => [mockDetailSidebarMode, mockSetDetailSidebarMode],
}))
let mockIsHovering = true
let mockKeyPressCallback: ((e: { preventDefault: () => void }) => void) | null = null
vi.mock('ahooks', () => ({
useHover: () => mockIsHovering,
}))
vi.mock('@tanstack/react-hotkeys', () => ({
useHotkey: (_hotkey: string, cb: (e: { preventDefault: () => void }) => void) => {
mockKeyPressCallback = cb
},
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('../../base/divider', () => ({
default: ({ className }: { className?: string }) => <hr data-testid="divider" className={className} />,
}))
vi.mock('../app-info', () => ({
default: ({ expand }: { expand: boolean }) => (
<div data-testid="app-info" data-expand={expand} />
),
AppInfoView: ({ expand }: { expand: boolean }) => (
<div data-testid="app-info" data-expand={expand} />
),
}))
vi.mock('../dataset-info', () => ({
default: ({ expand }: { expand: boolean }) => (
<div data-testid="dataset-info" data-expand={expand} />
),
}))
vi.mock('../nav-link', () => ({
default: ({ name, href, mode }: { name: string, href: string, mode?: string }) => (
<a data-testid={`nav-link-${name}`} href={href} data-mode={mode}>{name}</a>
),
}))
vi.mock('../toggle-button', () => ({
default: ({ expand, handleToggle, className }: { expand: boolean, handleToggle: () => void, className?: string }) => (
<button type="button" data-testid="toggle-button" data-expand={expand} onClick={handleToggle} className={className}>
Toggle
</button>
),
}))
const MockIcon = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
const navigation = [
{ name: 'Overview', href: '/overview', icon: MockIcon, selectedIcon: MockIcon },
{ name: 'Logs', href: '/logs', icon: MockIcon, selectedIcon: MockIcon },
]
describe('AppDetailNav', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDetailSidebarMode = 'expand'
mockIsHovering = true
mockKeyPressCallback = null
})
describe('Normal sidebar mode', () => {
it('should render AppInfo when iconType is app', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('app-info')).toBeInTheDocument()
expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'true')
})
it('should render DatasetInfo when iconType is dataset', () => {
render(<AppDetailNav navigation={navigation} iconType="dataset" />)
expect(screen.getByTestId('dataset-info')).toBeInTheDocument()
})
it('should render navigation links', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('nav-link-Overview')).toBeInTheDocument()
expect(screen.getByTestId('nav-link-Logs')).toBeInTheDocument()
})
it('should render divider', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('divider')).toBeInTheDocument()
})
it('should apply expanded width class', () => {
const { container } = render(<AppDetailNav navigation={navigation} />)
const sidebar = container.firstElementChild as HTMLElement
expect(sidebar).toHaveClass('w-55')
})
it('should apply collapsed width class', () => {
mockDetailSidebarMode = 'collapse'
const { container } = render(<AppDetailNav navigation={navigation} />)
const sidebar = container.firstElementChild as HTMLElement
expect(sidebar).toHaveClass('w-14')
})
it('should render extraInfo when iconType is dataset and extraInfo provided', () => {
render(
<AppDetailNav
navigation={navigation}
iconType="dataset"
extraInfo={mode => <div data-testid="extra-info" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('extra-info')).toBeInTheDocument()
})
it('should not render extraInfo when iconType is app', () => {
render(
<AppDetailNav
navigation={navigation}
extraInfo={mode => <div data-testid="extra-info" data-mode={mode} />}
/>,
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Navigation mode', () => {
it('should pass expand mode to nav links when expanded', () => {
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('nav-link-Overview')).toHaveAttribute('data-mode', 'expand')
})
it('should pass collapse mode to nav links when collapsed', () => {
mockDetailSidebarMode = 'collapse'
render(<AppDetailNav navigation={navigation} />)
expect(screen.getByTestId('nav-link-Overview')).toHaveAttribute('data-mode', 'collapse')
})
})
describe('Toggle behavior', () => {
it('should collapse detail sidebar on toggle', async () => {
const user = userEvent.setup()
render(<AppDetailNav navigation={navigation} />)
await user.click(screen.getByTestId('toggle-button'))
expect(mockSetDetailSidebarMode).toHaveBeenCalledWith('collapse')
})
it('should toggle from collapse to expand', async () => {
const user = userEvent.setup()
mockDetailSidebarMode = 'collapse'
render(<AppDetailNav navigation={navigation} />)
await user.click(screen.getByTestId('toggle-button'))
expect(mockSetDetailSidebarMode).toHaveBeenCalledWith('expand')
})
})
describe('Disabled navigation items', () => {
it('should render disabled navigation items', () => {
const navWithDisabled = [
...navigation,
{ name: 'Disabled', href: '/disabled', icon: MockIcon, selectedIcon: MockIcon, disabled: true },
]
render(<AppDetailNav navigation={navWithDisabled} />)
expect(screen.getByTestId('nav-link-Disabled')).toBeInTheDocument()
})
})
describe('Keyboard shortcut', () => {
it('should toggle sidebar on Mod+B', () => {
render(<AppDetailNav navigation={navigation} />)
const cb = mockKeyPressCallback
expect(cb).not.toBeNull()
act(() => {
cb!({ preventDefault: vi.fn() })
})
expect(mockSetDetailSidebarMode).toHaveBeenCalledWith('collapse')
})
})
describe('Hover-based toggle button visibility', () => {
it('should hide toggle button when not hovering', () => {
mockIsHovering = false
render(<AppDetailNav navigation={navigation} />)
expect(screen.queryByTestId('toggle-button')).not.toBeInTheDocument()
})
})
})

View File

@ -1,119 +0,0 @@
import type { AppInfoActions } from './app-info/use-app-info-actions'
import type { NavIcon } from './nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
import AppInfo from './app-info'
import { getAppModeLabel } from './app-info/app-mode-labels'
import NavLink from './nav-link'
type Props = Readonly<{
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
}>
appInfoActions?: AppInfoActions
}>
const AppSidebarDropdown = ({ navigation, appInfoActions }: Props) => {
const { t } = useTranslation()
const appDetail = useAppStore(state => state.appDetail)
const [detailExpand, setDetailExpand] = useState(false)
const [open, setOpen] = useState(false)
if (!appDetail)
return null
return (
<>
<div className="fixed top-2 left-2 z-20">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
'data-popup-open:bg-background-default-hover',
)}
>
<AppIcon
size="small"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<span className="i-ri-menu-line size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className={cn('w-[305px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg')}>
<div className="p-2">
<div
className="flex cursor-pointer flex-col gap-2 rounded-lg p-2 pb-2.5 hover:bg-state-base-hover"
onClick={() => {
if (appInfoActions)
appInfoActions.setPanelOpen(true)
else
setDetailExpand(true)
setOpen(false)
}}
>
<div className="flex items-center justify-between self-stretch">
<AppIcon
size="large"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className="flex items-center justify-center rounded-md p-0.5">
<div className="flex size-5 items-center justify-center">
<span className="i-ri-equalizer-2-line size-4 text-text-tertiary" />
</div>
</div>
</div>
<div className="flex flex-col items-start gap-1">
<div className="flex w-full">
<div className="truncate system-md-semibold text-text-secondary">{appDetail.name}</div>
</div>
<div className="system-2xs-medium-uppercase text-text-tertiary">{getAppModeLabel(appDetail.mode, t)}</div>
</div>
</div>
</div>
<div className="px-4">
<Divider bgStyle="gradient" />
</div>
<nav className="space-y-0.5 px-3 pt-4 pb-6">
{navigation.map((item, index) => {
return (
<NavLink key={index} mode="expand" iconMap={{ selected: item.selectedIcon, normal: item.icon }} name={item.name} href={item.href} />
)
})}
</nav>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
{!appInfoActions && (
<div className="z-20">
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />
</div>
)}
</>
)
}
export default AppSidebarDropdown

View File

@ -1,153 +0,0 @@
import type { NavIcon } from './nav-link'
import type { DataSet } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
RiMenuLine,
} from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useKnowledge } from '@/hooks/use-knowledge'
import { DOC_FORM_TEXT } from '@/models/datasets'
import { useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
import Effect from '../base/effect'
import ExtraInfo from '../datasets/extra-info'
import Dropdown from './dataset-info/dropdown'
import NavLink from './nav-link'
type DatasetSidebarDropdownProps = {
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
disabled?: boolean
}>
}
const DatasetSidebarDropdown = ({
navigation,
}: DatasetSidebarDropdownProps) => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const { data: relatedApps } = useDatasetRelatedApps(dataset.id)
const [open, setOpen] = useState(false)
const iconInfo = dataset.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
const isExternalProvider = dataset.provider === 'external'
const { formatIndexingTechniqueAndMethod } = useKnowledge()
if (!dataset)
return null
return (
<>
<div className="fixed top-2 left-2 z-20">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
'data-popup-open:bg-background-default-hover',
)}
>
<AppIcon
size="small"
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<RiMenuLine className="size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative w-[216px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg">
<Effect className="top-[-22px] -left-5 opacity-15" />
<div className="flex flex-col gap-y-2 p-4">
<div className="flex items-center justify-between">
<AppIcon
size="medium"
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<Dropdown expand />
</div>
<div className="flex flex-col gap-y-1 pb-0.5">
<div
className="truncate system-md-semibold text-text-secondary"
title={dataset.name}
>
{dataset.name}
</div>
<div className="system-2xs-medium-uppercase text-text-tertiary">
{isExternalProvider && t('externalTag', { ns: 'dataset' })}
{!!(!isExternalProvider && dataset.doc_form && dataset.indexing_technique) && (
<div className="flex items-center gap-x-2">
<span>{t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
</div>
)}
</div>
</div>
{!!dataset.description && (
<p className="line-clamp-3 system-xs-regular text-text-tertiary first-letter:capitalize">
{dataset.description}
</p>
)}
</div>
<div className="px-4 py-2">
<Divider
type="horizontal"
bgStyle="gradient"
className="my-0 h-px bg-linear-to-r from-divider-subtle to-background-gradient-mask-transparent"
/>
</div>
<nav className="flex min-h-[200px] grow flex-col gap-y-0.5 px-3 py-2">
{navigation.map((item, index) => {
return (
<NavLink
key={index}
mode="expand"
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
<ExtraInfo
relatedApps={relatedApps}
expand
documentCount={dataset.document_count}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)
}
export default DatasetSidebarDropdown

View File

@ -1,134 +0,0 @@
import type { AppInfoActions } from './app-info/use-app-info-actions'
import type { NavIcon } from './nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useHover } from 'ahooks'
import * as React from 'react'
import { useCallback } from 'react'
import { useDetailSidebarMode } from '@/app/components/main-nav/storage'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Divider from '../base/divider'
import AppInfo, { AppInfoView } from './app-info'
import DatasetInfo from './dataset-info'
import NavLink from './nav-link'
import ToggleButton from './toggle-button'
type IAppDetailNavProps = {
iconType?: 'app' | 'dataset'
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
appInfoActions?: AppInfoActions
}
const AppDetailNav = ({
navigation,
extraInfo,
renderHeader,
renderNavigation,
iconType = 'app',
appInfoActions,
}: IAppDetailNavProps) => {
const [detailSidebarMode, setDetailSidebarMode] = useDetailSidebarMode()
const sidebarRef = React.useRef<HTMLDivElement>(null)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const expand = detailSidebarMode === 'expand'
const handleToggle = useCallback(() => {
setDetailSidebarMode(detailSidebarMode === 'expand' ? 'collapse' : 'expand')
}, [detailSidebarMode, setDetailSidebarMode])
const isHoveringSidebar = useHover(sidebarRef)
useHotkey('Mod+B', (e) => {
e.preventDefault()
handleToggle()
}, {
ignoreInputs: true,
})
return (
<div
ref={sidebarRef}
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
expand ? 'w-55' : 'w-14',
)}
>
<div
className={cn(
'shrink-0',
expand ? 'p-2' : 'p-1',
)}
>
{renderHeader
? renderHeader(detailSidebarMode)
: iconType === 'app' && (
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
<div className="relative px-4 py-2">
<Divider
type="horizontal"
bgStyle={expand ? 'gradient' : 'solid'}
className={cn(
'my-0 h-px',
expand
? 'bg-linear-to-r from-divider-subtle to-background-gradient-mask-transparent'
: 'bg-divider-subtle',
)}
/>
{!isMobile && isHoveringSidebar && (
<ToggleButton
className="absolute top-[-3.5px] -right-3 z-20"
expand={expand}
handleToggle={handleToggle}
/>
)}
</div>
<nav
className={cn(
'flex grow flex-col gap-y-0.5',
expand ? 'px-3 py-2' : 'p-3',
)}
>
{renderNavigation
? renderNavigation(detailSidebarMode)
: navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={detailSidebarMode}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
{iconType !== 'app' && extraInfo && extraInfo(detailSidebarMode)}
</div>
)
}
export default React.memo(AppDetailNav)

View File

@ -1,60 +0,0 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import SnippetInfo from '..'
vi.mock('../dropdown', () => ({
default: () => <div data-testid="snippet-info-dropdown" />,
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
updatedAt: '2026-03-25 10:00',
usage: '12',
tags: [],
status: undefined,
}
describe('SnippetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests for the collapsed and expanded sidebar header states.
describe('Rendering', () => {
it('should render the expanded snippet details and dropdown when expand is true', () => {
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
})
it('should hide the expanded-only content when expand is false', () => {
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
})
})
// Edge cases around optional snippet fields should not break the header layout.
describe('Edge Cases', () => {
it('should omit the description block when the snippet has no description', () => {
render(
<SnippetInfo
expand={true}
snippet={{ ...mockSnippet, description: '' }}
/>,
)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
})
})
})

View File

@ -1,46 +0,0 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
if (!expand)
return null
return (
<div className="flex flex-col px-2 pt-2 pb-1">
<div className="flex flex-col gap-2 rounded-xl p-2">
<div className="flex items-center justify-end">
<SnippetInfoDropdown snippet={snippet} />
</div>
<div className="min-w-0">
<div className="truncate system-md-semibold text-text-secondary">
{snippet.name}
</div>
<div className="pt-1 system-2xs-medium-uppercase text-text-tertiary">
{t('typeLabel')}
</div>
</div>
{snippet.description && (
<p className="line-clamp-3 system-xs-regular break-words text-text-tertiary">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@ -1,45 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { InputVarType } from '@/app/components/workflow/types'
import SelectTypeItem from '../index'
describe('SelectTypeItem', () => {
// Rendering pathways based on type and selection state
describe('Rendering', () => {
it('should render ok', () => {
// Arrange
const { container } = render(
<SelectTypeItem
type={InputVarType.textInput}
selected={false}
onClick={vi.fn()}
/>,
)
// Assert
expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})
// User interaction outcomes
describe('Interactions', () => {
it('should trigger onClick when item is pressed', () => {
const handleClick = vi.fn()
// Arrange
render(
<SelectTypeItem
type={InputVarType.paragraph}
selected={false}
onClick={handleClick}
/>,
)
// Act
fireEvent.click(screen.getByText('appDebug.variableConfig.paragraph'))
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -1,47 +0,0 @@
'use client'
import type { FC } from 'react'
import type { InputVarType } from '@/app/components/workflow/types'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
type ISelectTypeItemProps = {
type: InputVarType
selected: boolean
onClick: () => void
}
type VariableConfigTypeKey = I18nKeysByPrefix<'appDebug', 'variableConfig.'>
const i18nTypeMap: Partial<Record<InputVarType, VariableConfigTypeKey>> = {
'file': 'single-file',
'file-list': 'multi-files',
}
const SelectTypeItem: FC<ISelectTypeItemProps> = ({
type,
selected,
onClick,
}) => {
const { t } = useTranslation()
const typeKey = i18nTypeMap[type] ?? type as VariableConfigTypeKey
const typeName = t(`variableConfig.${typeKey}`, { ns: 'appDebug' })
return (
<div
className={cn(
'flex h-[58px] flex-col items-center justify-center space-y-1 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary',
selected ? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-xs-medium shadow-xs' : 'cursor-pointer system-xs-regular hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
)}
onClick={onClick}
>
<div className="shrink-0">
<InputVarTypeIcon type={type} className="size-5" />
</div>
<span>{typeName}</span>
</div>
)
}
export default React.memo(SelectTypeItem)

View File

@ -1,908 +0,0 @@
import type { AgentConfig } from '@/models/debug'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AgentStrategy } from '@/types/app'
import AssistantTypePicker from '../index'
// Test utilities
const defaultAgentConfig: AgentConfig = {
enabled: true,
max_iteration: 3,
strategy: AgentStrategy.functionCall,
tools: [],
}
const defaultProps = {
value: 'chat',
disabled: false,
onChange: vi.fn(),
isFunctionCall: true,
isChatModel: true,
agentConfig: defaultAgentConfig,
onAgentSettingChange: vi.fn(),
}
const renderComponent = (props: Partial<React.ComponentProps<typeof AssistantTypePicker>> = {}) => {
const mergedProps = { ...defaultProps, ...props }
return render(<AssistantTypePicker {...mergedProps} />)
}
// Helper to get option element by description (which is unique per option)
const getOptionByDescription = (descriptionRegex: RegExp) => {
const description = screen.getByText(descriptionRegex)
return description.parentElement as HTMLElement
}
describe('AssistantTypePicker', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderComponent()
// Assert
// Assert
expect(screen.getByText(/chatAssistant.name/i))!.toBeInTheDocument()
})
it('should render chat assistant by default when value is "chat"', () => {
// Arrange & Act
renderComponent({ value: 'chat' })
// Assert
// Assert
expect(screen.getByText(/chatAssistant.name/i))!.toBeInTheDocument()
})
it('should render agent assistant when value is "agent"', () => {
// Arrange & Act
renderComponent({ value: 'agent' })
// Assert
// Assert
expect(screen.getByText(/agentAssistant.name/i))!.toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should use provided value prop', () => {
// Arrange & Act
renderComponent({ value: 'agent' })
// Assert
// Assert
expect(screen.getByText(/agentAssistant.name/i))!.toBeInTheDocument()
})
it('should handle agentConfig prop', () => {
// Arrange
const customAgentConfig: AgentConfig = {
enabled: true,
max_iteration: 10,
strategy: AgentStrategy.react,
tools: [],
}
// Act
expect(() => {
renderComponent({ agentConfig: customAgentConfig })
}).not.toThrow()
// Assert
// Assert
expect(screen.getByText(/chatAssistant.name/i))!.toBeInTheDocument()
})
it('should handle undefined agentConfig prop', () => {
// Arrange & Act
expect(() => {
renderComponent({ agentConfig: undefined })
}).not.toThrow()
// Assert
// Assert
expect(screen.getByText(/chatAssistant.name/i))!.toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should open dropdown when clicking trigger', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Both options should be visible
await waitFor(() => {
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
expect(chatOptions.length).toBeGreaterThan(1)
expect(agentOptions.length).toBeGreaterThan(0)
})
})
it('should call onChange when selecting chat assistant', async () => {
// Arrange
const user = userEvent.setup()
const onChange = vi.fn()
renderComponent({ value: 'agent', onChange })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Wait for dropdown to open and find chat option
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
})
// Find and click the chat option by its unique description
const chatOption = getOptionByDescription(/chatAssistant.description/i)
await user.click(chatOption)
// Assert
expect(onChange).toHaveBeenCalledWith('chat')
})
it('should call onChange when selecting agent assistant', async () => {
// Arrange
const user = userEvent.setup()
const onChange = vi.fn()
renderComponent({ value: 'chat', onChange })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown to open and click agent option
await waitFor(() => {
expect(screen.getByText(/agentAssistant.description/i))!.toBeInTheDocument()
})
const agentOption = getOptionByDescription(/agentAssistant.description/i)
await user.click(agentOption)
// Assert
expect(onChange).toHaveBeenCalledWith('agent')
})
it('should close dropdown when selecting chat assistant', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent' })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Wait for dropdown and select chat
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
})
const chatOption = getOptionByDescription(/chatAssistant.description/i)
await user.click(chatOption)
// Assert - Dropdown should close (descriptions should not be visible)
await waitFor(() => {
expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument()
})
})
it('should not close dropdown when selecting agent assistant', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'chat' })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown and select agent
await waitFor(() => {
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
expect(agentOptions.length).toBeGreaterThan(0)
})
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
await user.click(agentOptions[0]!)
// Assert - Dropdown should remain open (agent settings should be visible)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
})
it('should not call onChange when clicking same value', async () => {
// Arrange
const user = userEvent.setup()
const onChange = vi.fn()
renderComponent({ value: 'chat', onChange })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown and click same option
await waitFor(() => {
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
expect(chatOptions.length).toBeGreaterThan(1)
})
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
await user.click(chatOptions[1]!)
// Assert
expect(onChange).not.toHaveBeenCalled()
})
})
// Disabled state
describe('Disabled State', () => {
it('should not respond to clicks when disabled', async () => {
// Arrange
const user = userEvent.setup()
const onChange = vi.fn()
renderComponent({ disabled: true, onChange })
// Act - Open dropdown (dropdown can still open when disabled)
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown to open
await waitFor(() => {
expect(screen.getByText(/agentAssistant.description/i))!.toBeInTheDocument()
})
// Act - Try to click an option
const agentOption = getOptionByDescription(/agentAssistant.description/i)
await user.click(agentOption)
// Assert - onChange should not be called (options are disabled)
expect(onChange).not.toHaveBeenCalled()
})
it('should not show agent config UI when disabled', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: true })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Assert - Agent settings option should not be visible
await waitFor(() => {
expect(screen.queryByText(/agent.setting.name/i)).not.toBeInTheDocument()
})
})
it('should show agent config UI when not disabled', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Assert - Agent settings option should be visible
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
})
})
// Agent Settings Modal
describe('Agent Settings Modal', () => {
it('should open agent settings modal when clicking agent config UI', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Click agent settings
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(/common.operation.save/i))!.toBeInTheDocument()
})
})
it('should not open agent settings when value is not agent', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'chat', disabled: false })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown to open
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
})
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
// Assert - Agent settings modal should not appear (value is 'chat')
expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument()
})
it('should call onAgentSettingChange when saving agent settings', async () => {
// Arrange
const user = userEvent.setup()
const onAgentSettingChange = vi.fn()
renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
// Act - Open dropdown and agent settings
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Wait for modal and click save
await waitFor(() => {
expect(screen.getByText(/common.operation.save/i))!.toBeInTheDocument()
})
const saveButton = screen.getByText(/common.operation.save/i)
await user.click(saveButton)
// Assert
expect(onAgentSettingChange).toHaveBeenCalledWith(defaultAgentConfig)
})
it('should close modal when saving agent settings', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown, agent settings, and save
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
await waitFor(() => {
expect(screen.getByText(/appDebug.agent.setting.name/i))!.toBeInTheDocument()
})
const saveButton = screen.getByText(/common.operation.save/i)
await user.click(saveButton)
// Assert
await waitFor(() => {
expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument()
})
})
it('should close modal when canceling agent settings', async () => {
// Arrange
const user = userEvent.setup()
const onAgentSettingChange = vi.fn()
renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
// Act - Open dropdown, agent settings, and cancel
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
await waitFor(() => {
expect(screen.getByText(/common.operation.save/i))!.toBeInTheDocument()
})
const cancelButton = screen.getByText(/common.operation.cancel/i)
await user.click(cancelButton)
// Assert
await waitFor(() => {
expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument()
})
expect(onAgentSettingChange).not.toHaveBeenCalled()
})
it('should close dropdown when opening agent settings', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown and agent settings
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert - Modal should be open and dropdown should close
await waitFor(() => {
expect(screen.getByText(/common.operation.save/i))!.toBeInTheDocument()
})
// The dropdown should be closed (agent settings description should not be visible)
await waitFor(() => {
const descriptions = screen.queryAllByText(/agent.setting.description/i)
expect(descriptions.length).toBe(0)
})
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle rapid toggle clicks', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
await user.click(trigger)
await user.click(trigger)
// Assert - Should not crash
// Assert - Should not crash
expect(trigger)!.toBeInTheDocument()
})
it('should handle multiple rapid selection changes', async () => {
// Arrange
const user = userEvent.setup()
const onChange = vi.fn()
renderComponent({ value: 'chat', onChange })
// Act - Open and select agent
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agentAssistant.description/i))!.toBeInTheDocument()
})
// Click agent option - this stays open because value is 'agent'
const agentOption = getOptionByDescription(/agentAssistant.description/i)
await user.click(agentOption)
// Assert - onChange should have been called once to switch to agent
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1)
})
expect(onChange).toHaveBeenCalledWith('agent')
})
it('should handle missing callback functions gracefully', async () => {
// Arrange
const user = userEvent.setup()
// Act & Assert - Should not crash
expect(() => {
renderComponent({
onChange: undefined!,
onAgentSettingChange: undefined!,
})
}).not.toThrow()
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
})
it('should handle empty agentConfig', async () => {
// Arrange & Act
expect(() => {
renderComponent({ agentConfig: {} as AgentConfig })
}).not.toThrow()
// Assert
// Assert
expect(screen.getByText(/chatAssistant.name/i))!.toBeInTheDocument()
})
describe('should render with different prop combinations', () => {
const combinations = [
{ value: 'chat' as const, disabled: true, isFunctionCall: true, isChatModel: true },
{ value: 'agent' as const, disabled: false, isFunctionCall: false, isChatModel: false },
{ value: 'agent' as const, disabled: true, isFunctionCall: true, isChatModel: false },
{ value: 'chat' as const, disabled: false, isFunctionCall: false, isChatModel: true },
]
it.each(combinations)(
'value=$value, disabled=$disabled, isFunctionCall=$isFunctionCall, isChatModel=$isChatModel',
(combo) => {
// Arrange & Act
renderComponent(combo)
// Assert
const expectedText = combo.value === 'agent' ? 'agentAssistant.name' : 'chatAssistant.name'
expect(screen.getByText(new RegExp(expectedText, 'i')))!.toBeInTheDocument()
},
)
})
})
// Accessibility
describe('Accessibility', () => {
it('should render interactive dropdown items', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Both options should be visible and clickable
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i))!.toBeInTheDocument()
})
// Verify we can interact with option elements using helper function
const chatOption = getOptionByDescription(/chatAssistant.description/i)
const agentOption = getOptionByDescription(/agentAssistant.description/i)
expect(chatOption)!.toBeInTheDocument()
expect(agentOption)!.toBeInTheDocument()
})
})
// SelectItem Component
describe('SelectItem Component', () => {
it('should show checked state for selected option', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'chat' })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Both options should be visible with radio components
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i))!.toBeInTheDocument()
})
// The SelectItem components render with different visual states
// based on isChecked prop - we verify both options are rendered
const chatOption = getOptionByDescription(/chatAssistant.description/i)
const agentOption = getOptionByDescription(/agentAssistant.description/i)
expect(chatOption)!.toBeInTheDocument()
expect(agentOption)!.toBeInTheDocument()
})
it('should render description text', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Descriptions should be visible
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i))!.toBeInTheDocument()
})
})
it('should show Radio component for each option', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Radio components should be present (both options visible)
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i))!.toBeInTheDocument()
})
})
})
// Agent Setting Integration
describe('AgentSetting Integration', () => {
it('should show function call mode when isFunctionCall is true', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', isFunctionCall: true, isChatModel: false })
// Act - Open dropdown and settings modal
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(/common.operation.save/i))!.toBeInTheDocument()
})
expect(screen.getByText(/appDebug.agent.agentModeType.functionCall/i))!.toBeInTheDocument()
})
it('should show built-in prompt when isFunctionCall is false', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', isFunctionCall: false, isChatModel: true })
// Act - Open dropdown and settings modal
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(/common.operation.save/i))!.toBeInTheDocument()
})
expect(screen.getByText(/tools.builtInPromptTitle/i))!.toBeInTheDocument()
})
it('should initialize max iteration from agentConfig payload', async () => {
// Arrange
const user = userEvent.setup()
const customConfig: AgentConfig = {
enabled: true,
max_iteration: 10,
strategy: AgentStrategy.react,
tools: [],
}
renderComponent({ value: 'agent', agentConfig: customConfig })
// Act - Open dropdown and settings modal
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert
await screen.findByText(/common.operation.save/i)
const maxIterationInput = await screen.findByRole('spinbutton')
expect(maxIterationInput)!.toHaveValue(10)
})
})
// Keyboard Navigation
describe('Keyboard Navigation', () => {
it('should support closing dropdown with Escape key', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
})
// Press Escape
await user.keyboard('{Escape}')
// Assert - Dropdown should close
await waitFor(() => {
expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument()
})
})
it('should allow keyboard focus on trigger element', () => {
// Arrange
renderComponent()
// Act - Get trigger and verify it can receive focus
const trigger = screen.getByText(/chatAssistant.name/i)
// Assert - Element should be focusable
// Assert - Element should be focusable
expect(trigger)!.toBeInTheDocument()
expect(trigger.parentElement)!.toBeInTheDocument()
})
it('should allow keyboard focus on dropdown options', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
})
// Get options
const chatOption = getOptionByDescription(/chatAssistant.description/i)
const agentOption = getOptionByDescription(/agentAssistant.description/i)
// Assert - Options should be focusable
// Assert - Options should be focusable
expect(chatOption)!.toBeInTheDocument()
expect(agentOption)!.toBeInTheDocument()
// Verify options exist and can receive focus programmatically
// Note: focus() doesn't always update document.activeElement in JSDOM
// so we just verify the elements are interactive
act(() => {
chatOption.focus()
})
// The element should have received the focus call even if activeElement isn't updated
expect(chatOption.tabIndex).toBeDefined()
})
it('should maintain keyboard accessibility for all interactive elements', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent' })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Assert - Agent settings button should be focusable
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i))!.toBeInTheDocument()
})
const agentSettings = screen.getByText(/agent.setting.name/i)
expect(agentSettings)!.toBeInTheDocument()
})
})
// ARIA Attributes
describe('ARIA Attributes', () => {
it('should have proper ARIA state for dropdown', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Check initial state
const triggerButton = screen.getByRole('button', { name: /chatAssistant\.name/i })
expect(triggerButton).toHaveAttribute('aria-expanded', 'false')
// Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - State should change to open
await waitFor(() => {
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
})
})
it('should have proper data-state attribute', () => {
// Arrange & Act
renderComponent()
// Assert - Trigger should expose expanded state for accessibility
const triggerButton = screen.getByRole('button', { name: /chatAssistant\.name/i })
expect(triggerButton).toBeInTheDocument()
expect(triggerButton).toHaveAttribute('aria-expanded')
// Should start in closed state
// Should start in closed state
expect(triggerButton).toHaveAttribute('aria-expanded', 'false')
})
it('should maintain accessible structure for screen readers', () => {
// Arrange & Act
renderComponent({ value: 'chat' })
// Assert - Text content should be accessible
// Assert - Text content should be accessible
expect(screen.getByText(/chatAssistant.name/i))!.toBeInTheDocument()
// Icons should have proper structure
const { container } = renderComponent()
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
it('should provide context through text labels', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - All options should have descriptive text
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i))!.toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i))!.toBeInTheDocument()
})
// Title text should be visible
// Title text should be visible
expect(screen.getByText(/assistantType.name/i))!.toBeInTheDocument()
})
})
})

View File

@ -1,183 +0,0 @@
'use client'
import type { FC } from 'react'
import type { AgentConfig } from '@/models/debug'
import { cn } from '@langgenius/dify-ui/cn'
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { Radio } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { RiArrowDownSLine } from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { Settings04 } from '@/app/components/base/icons/src/vender/line/general'
import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication'
import { BubbleText } from '@/app/components/base/icons/src/vender/solid/education'
import AgentSetting from '../agent/agent-setting'
type Props = Readonly<{
value: string
disabled: boolean
onChange: (value: string) => void
isFunctionCall: boolean
isChatModel: boolean
agentConfig?: AgentConfig
onAgentSettingChange: (payload: AgentConfig) => void
}>
type ItemProps = {
text: string
disabled: boolean
value: string
isChecked: boolean
description: string
Icon: any
}
const SelectItem: FC<ItemProps> = ({ text, value, Icon, isChecked, description, disabled }) => {
return (
<FieldItem>
<FieldLabel
className={cn(disabled ? 'opacity-50' : 'cursor-pointer', isChecked ? 'border-2 border-indigo-600 shadow-sm' : 'border border-gray-100', 'mb-2 rounded-xl bg-gray-25 p-3 pr-4 hover:bg-gray-50')}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="mr-3 rounded-lg bg-indigo-50 p-1">
<Icon className="size-4 text-indigo-600" />
</div>
<div className="text-sm/5 font-medium text-gray-900">{text}</div>
</div>
<Radio value={value} disabled={disabled} />
</div>
<div className="ml-9 text-xs leading-[18px] font-normal text-gray-500">{description}</div>
</FieldLabel>
</FieldItem>
)
}
const AssistantTypePicker: FC<Props> = ({
value,
disabled,
onChange,
onAgentSettingChange,
isFunctionCall,
isChatModel,
agentConfig,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleChange = (chosenValue: string) => {
if (value === chosenValue)
return
onChange(chosenValue)
if (chosenValue !== 'agent')
setOpen(false)
}
const isAgent = value === 'agent'
const [isShowAgentSetting, setIsShowAgentSetting] = useState(false)
const agentConfigUI = (
<>
<div className="my-4 h-px bg-gray-100"></div>
<div
className={cn(isAgent ? 'group cursor-pointer hover:bg-primary-50' : 'opacity-30', 'rounded-xl bg-gray-50 p-3 pr-4')}
onClick={() => {
if (isAgent) {
setOpen(false)
setIsShowAgentSetting(true)
}
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="mr-3 rounded-lg bg-gray-200 p-1 group-hover:bg-white">
<Settings04 className="h-4 w-4 text-gray-600 group-hover:text-[#155EEF]" />
</div>
<div className="text-sm leading-5 font-medium text-gray-900 group-hover:text-[#155EEF]">{t('agent.setting.name', { ns: 'appDebug' })}</div>
</div>
<ArrowUpRight className="h-4 w-4 text-gray-500 group-hover:text-[#155EEF]" />
</div>
<div className="ml-9 text-xs leading-[18px] font-normal text-gray-500">{t('agent.setting.description', { ns: 'appDebug' })}</div>
</div>
</>
)
return (
<>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn(open && 'bg-gray-50', 'flex h-8 cursor-pointer items-center space-x-1 rounded-lg border border-black/5 px-3 text-indigo-600 select-none')} />
)}
>
{isAgent ? <BubbleText className="size-3" /> : <CuteRobot className="size-3" />}
<div className="text-xs font-medium">{t(`assistantType.${isAgent ? 'agentAssistant' : 'chatAssistant'}.name`, { ns: 'appDebug' })}</div>
<RiArrowDownSLine className="size-3" />
</PopoverTrigger>
<PopoverContent
placement="bottom-end"
sideOffset={8}
alignOffset={-2}
popupClassName="relative left-0.5 w-[480px] rounded-xl border border-black/8 bg-white p-6 shadow-lg"
>
<FieldRoot name="assistant_type" className="contents">
<FieldsetRoot
render={(
<RadioGroup
value={isAgent ? 'agent' : 'chat'}
onValueChange={handleChange}
disabled={disabled}
className="flex-col items-stretch gap-0"
/>
)}
>
<FieldsetLegend className="mb-2 py-0 text-sm/5 font-semibold text-gray-900">
{t('assistantType.name', { ns: 'appDebug' })}
</FieldsetLegend>
<SelectItem
Icon={BubbleText}
value="chat"
disabled={disabled}
text={t('assistantType.chatAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.chatAssistant.description', { ns: 'appDebug' })}
isChecked={!isAgent}
/>
<SelectItem
Icon={CuteRobot}
value="agent"
disabled={disabled}
text={t('assistantType.agentAssistant.name', { ns: 'appDebug' })}
description={t('assistantType.agentAssistant.description', { ns: 'appDebug' })}
isChecked={isAgent}
/>
</FieldsetRoot>
</FieldRoot>
{!disabled && agentConfigUI}
</PopoverContent>
</Popover>
{isShowAgentSetting && (
<AgentSetting
isFunctionCall={isFunctionCall}
payload={agentConfig as AgentConfig}
isChatModel={isChatModel}
onSave={(payloadNew) => {
onAgentSettingChange(payloadNew)
setIsShowAgentSetting(false)
}}
onCancel={() => setIsShowAgentSetting(false)}
/>
)}
</>
)
}
export default React.memo(AssistantTypePicker)

View File

@ -1,42 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ContrlBtnGroup from '../index'
describe('ContrlBtnGroup', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering fixed action buttons
describe('Rendering', () => {
it('should render buttons when rendered', () => {
// Arrange
const onSave = vi.fn()
const onReset = vi.fn()
// Act
render(<ContrlBtnGroup onSave={onSave} onReset={onReset} />)
// Assert
expect(screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appDebug.operation.resetConfig' })).toBeInTheDocument()
})
})
// Handling click interactions
describe('Interactions', () => {
it('should invoke callbacks when buttons are clicked', () => {
// Arrange
const onSave = vi.fn()
const onReset = vi.fn()
render(<ContrlBtnGroup onSave={onSave} onReset={onReset} />)
// Act
fireEvent.click(screen.getByRole('button', { name: 'appDebug.operation.applyConfig' }))
fireEvent.click(screen.getByRole('button', { name: 'appDebug.operation.resetConfig' }))
// Assert
expect(onSave).toHaveBeenCalledTimes(1)
expect(onReset).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -1,24 +0,0 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import s from './style.module.css'
type IContrlBtnGroupProps = {
onSave: () => void
onReset: () => void
}
const ContrlBtnGroup: FC<IContrlBtnGroupProps> = ({ onSave, onReset }) => {
const { t } = useTranslation()
return (
<div className="fixed bottom-0 left-[224px] h-[64px] w-[519px]">
<div className={`${s.ctrlBtn} flex h-full items-center gap-2 bg-white pl-4`}>
<Button variant="primary" onClick={onSave}>{t('operation.applyConfig', { ns: 'appDebug' })}</Button>
<Button onClick={onReset}>{t('operation.resetConfig', { ns: 'appDebug' })}</Button>
</div>
</div>
)
}
export default React.memo(ContrlBtnGroup)

View File

@ -1,6 +0,0 @@
.ctrlBtn {
left: -16px;
right: -16px;
bottom: -16px;
border-top: 1px solid #F3F4F6;
}

View File

@ -1,29 +0,0 @@
import type { PromptVariable } from '@/models/debug'
import { describe, expect, it } from 'vitest'
import { replaceStringWithValues } from '../utils'
const promptVariables: PromptVariable[] = [
{ key: 'user', name: 'User', type: 'string' },
{ key: 'topic', name: 'Topic', type: 'string' },
]
describe('replaceStringWithValues', () => {
it('should replace placeholders when inputs have values', () => {
const template = 'Hello {{user}} talking about {{topic}}'
const result = replaceStringWithValues(template, promptVariables, { user: 'Alice', topic: 'cats' })
expect(result).toBe('Hello Alice talking about cats')
})
it('should use prompt variable name when value is missing', () => {
const template = 'Hi {{user}} from {{topic}}'
const result = replaceStringWithValues(template, promptVariables, {})
expect(result).toBe('Hi {{User}} from {{Topic}}')
})
it('should leave placeholder untouched when no variable is defined', () => {
const template = 'Unknown {{missing}} placeholder'
const result = replaceStringWithValues(template, promptVariables, {})
expect(result).toBe('Unknown {{missing}} placeholder')
})
})

View File

@ -1,13 +0,0 @@
import type { PromptVariable } from '@/models/debug'
export function replaceStringWithValues(str: string, promptVariables: PromptVariable[], inputs: Record<string, any>) {
return str.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
const name = inputs[key]
if (name) { // has set value
return name
}
const valueObj: PromptVariable | undefined = promptVariables.find(v => v.key === key)
return valueObj ? `{{${valueObj.name}}}` : match
})
}

View File

@ -1,283 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import CreateAppCard from '../new-app-card'
const mockReplace = vi.fn()
let mockWorkspacePermissionKeys: string[] = ['app.create_and_management']
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}),
}))
const mockOnPlanInfoChanged = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
vi.mock('@/next/dynamic', () => ({
default: (importFn: () => Promise<{ default: React.ComponentType }>) => {
const fnString = importFn.toString()
if (fnString.includes('create-app-modal') && !fnString.includes('create-from-dsl-modal')) {
return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-app-modal' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate as () => void, 'data-testid': 'to-template-modal' }, 'To Template'))
}
}
if (fnString.includes('create-app-dialog')) {
return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-template-dialog' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank as () => void, 'data-testid': 'to-blank-modal' }, 'To Blank'))
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: Record<string, unknown>) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-dsl-modal' }, 'Success'))
}
}
return () => null
},
}))
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
CreateFromDSLModalTab: {
FROM_URL: 'from-url',
},
}))
describe('CreateAppCard', () => {
const defaultRef = { current: null } as React.RefObject<HTMLDivElement | null>
beforeEach(() => {
vi.clearAllMocks()
mockWorkspacePermissionKeys = ['app.create_and_management']
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateAppCard ref={defaultRef} />)
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
})
it('should not render without app.create_and_management permission', () => {
mockWorkspacePermissionKeys = []
const { container } = render(<CreateAppCard ref={defaultRef} />)
expect(container).toBeEmptyDOMElement()
})
it('should render three create buttons', () => {
render(<CreateAppCard ref={defaultRef} />)
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
expect(screen.getByText('app.importDSL')).toBeInTheDocument()
})
it('should render all buttons as clickable', () => {
render(<CreateAppCard ref={defaultRef} />)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
buttons.forEach((button) => {
expect(button).not.toBeDisabled()
})
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(
<CreateAppCard ref={defaultRef} className="custom-class" />,
)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('custom-class')
})
it('should render with selectedAppType prop', () => {
render(<CreateAppCard ref={defaultRef} selectedAppType="chat" />)
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
})
})
describe('User Interactions - Create App Modal', () => {
it('should open create app modal when clicking Start from Blank', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
it('should close create app modal when clicking close button', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-create-modal'))
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
it('should call onSuccess and onPlanInfoChanged on create app success', () => {
const mockOnSuccess = vi.fn()
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
fireEvent.click(screen.getByTestId('success-create-modal'))
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
it('should switch from create modal to template dialog', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('to-template-modal'))
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
})
})
describe('User Interactions - Template Dialog', () => {
it('should open template dialog when clicking Start from Template', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
})
it('should close template dialog when clicking close button', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-template-dialog'))
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
})
it('should call onSuccess and onPlanInfoChanged on template success', () => {
const mockOnSuccess = vi.fn()
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
fireEvent.click(screen.getByTestId('success-template-dialog'))
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
it('should switch from template dialog to create modal', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('to-blank-modal'))
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
describe('User Interactions - DSL Import Modal', () => {
it('should open DSL modal when clicking Import DSL', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.importDSL'))
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when clicking close button', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.importDSL'))
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should call onSuccess and onPlanInfoChanged on DSL import success', () => {
const mockOnSuccess = vi.fn()
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('app.importDSL'))
fireEvent.click(screen.getByTestId('success-dsl-modal'))
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
})
describe('Styling', () => {
it('should have correct card container styling', () => {
const { container } = render(<CreateAppCard ref={defaultRef} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-41.5', 'rounded-xl', 'bg-background-default-dimmed')
})
it('should have proper button styling', () => {
render(<CreateAppCard ref={defaultRef} />)
const buttons = screen.getAllByRole('button')
buttons.forEach((button) => {
expect(button).toHaveClass('cursor-pointer')
})
})
})
describe('Edge Cases', () => {
it('should handle multiple modal opens/closes', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
fireEvent.click(screen.getByTestId('close-create-modal'))
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
fireEvent.click(screen.getByTestId('close-template-dialog'))
fireEvent.click(screen.getByText('app.importDSL'))
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should handle onSuccess not being provided', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(() => {
fireEvent.click(screen.getByTestId('success-create-modal'))
}).not.toThrow()
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
})
})
})

View File

@ -1,186 +0,0 @@
'use client'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import CreateResourceCard, {
createResourceCardActionClassName,
createResourceCardActionIconClassName,
} from '@/app/components/base/create-resource-card'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import AppListContext from '@/context/app-list-context'
import { useProviderContext } from '@/context/provider-context'
import dynamic from '@/next/dynamic'
import {
useRouter,
useSearchParams,
} from '@/next/navigation'
import { AppModeEnum } from '@/types/app'
import { hasPermission } from '@/utils/permission'
const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), {
ssr: false,
})
const CreateAppTemplateDialog = dynamic(() => import('@/app/components/app/create-app-dialog'), {
ssr: false,
})
const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), {
ssr: false,
})
type CreateAppCardProps = {
className?: string
isLoading?: boolean
onSuccess?: () => void
ref: React.RefObject<HTMLDivElement | null>
selectedAppType?: string
}
const CreateAppCard = ({
ref,
className,
isLoading = false,
onSuccess,
selectedAppType,
}: CreateAppCardProps) => {
const { t } = useTranslation()
const { onPlanInfoChanged } = useProviderContext()
const searchParams = useSearchParams()
const { replace } = useRouter()
const dslUrl = searchParams.get('remoteInstallUrl') || undefined
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
const [showNewAppModal, setShowNewAppModal] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(canCreateApp && !!dslUrl)
const activeTab = useMemo(() => {
if (dslUrl)
return CreateFromDSLModalTab.FROM_URL
return undefined
}, [dslUrl])
const defaultAppMode = useMemo(() => {
if (!selectedAppType || selectedAppType === 'all')
return undefined
return Object.values(AppModeEnum).includes(selectedAppType as AppModeEnum)
? selectedAppType as AppModeEnum
: undefined
}, [selectedAppType])
const controlHideCreateFromTemplatePanel = useContextSelector(AppListContext, ctx => ctx.controlHideCreateFromTemplatePanel)
useEffect(() => {
if (controlHideCreateFromTemplatePanel <= 0)
return
let cancelled = false
queueMicrotask(() => {
if (cancelled)
return
setShowNewAppTemplateDialog(false)
})
return () => {
cancelled = true
}
}, [controlHideCreateFromTemplatePanel])
useEffect(() => {
if (canCreateApp)
return
setShowNewAppModal(false)
setShowNewAppTemplateDialog(false)
setShowCreateFromDSLModal(false)
}, [canCreateApp])
if (!canCreateApp)
return null
return (
<>
<CreateResourceCard
ref={ref}
className={className}
isLoading={isLoading}
footer={(
<button
type="button"
onClick={() => setShowCreateFromDSLModal(true)}
className={createResourceCardActionClassName}
>
<span aria-hidden="true" className={`i-ri-file-upload-line ${createResourceCardActionIconClassName}`} />
<span className="min-w-0 grow truncate">{t('importDSL', { ns: 'app' })}</span>
</button>
)}
>
<button type="button" className={createResourceCardActionClassName} onClick={() => setShowNewAppModal(true)}>
<span aria-hidden="true" className={`i-ri-sticky-note-add-line ${createResourceCardActionIconClassName}`} />
<span className="min-w-0 grow truncate">{t('newApp.startFromBlank', { ns: 'app' })}</span>
</button>
<button type="button" className={createResourceCardActionClassName} onClick={() => setShowNewAppTemplateDialog(true)}>
<span aria-hidden="true" className={`i-ri-function-add-line ${createResourceCardActionIconClassName}`} />
<span className="min-w-0 grow truncate">{t('newApp.startFromTemplate', { ns: 'app' })}</span>
</button>
</CreateResourceCard>
{showNewAppModal && (
<CreateAppModal
show={showNewAppModal}
onClose={() => setShowNewAppModal(false)}
onSuccess={() => {
onPlanInfoChanged()
if (onSuccess)
onSuccess()
}}
onCreateFromTemplate={() => {
setShowNewAppTemplateDialog(true)
setShowNewAppModal(false)
}}
defaultAppMode={defaultAppMode}
/>
)}
{showNewAppTemplateDialog && (
<CreateAppTemplateDialog
show={showNewAppTemplateDialog}
onClose={() => setShowNewAppTemplateDialog(false)}
onSuccess={() => {
onPlanInfoChanged()
if (onSuccess)
onSuccess()
}}
onCreateFromBlank={() => {
setShowNewAppModal(true)
setShowNewAppTemplateDialog(false)
}}
/>
)}
{showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {
setShowCreateFromDSLModal(false)
if (dslUrl)
replace('/apps')
}}
activeTab={activeTab}
dslUrl={dslUrl}
onSuccess={() => {
onPlanInfoChanged()
if (onSuccess)
onSuccess()
}}
/>
)}
</>
)
}
CreateAppCard.displayName = 'CreateAppCard'
export default React.memo(CreateAppCard)

View File

@ -1,43 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
export const createResourceCardActionClassName = 'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-4 py-2 text-left system-sm-medium text-text-tertiary outline-hidden transition-colors hover:bg-background-default-dodge hover:text-text-secondary hover:shadow-xs hover:shadow-shadow-shadow-3 focus-visible:bg-background-default-dodge focus-visible:text-text-secondary focus-visible:shadow-xs focus-visible:shadow-shadow-shadow-3'
export const createResourceCardActionIconClassName = 'size-4 shrink-0 text-text-tertiary group-hover:text-text-secondary group-focus-visible:text-text-secondary'
type CreateResourceCardProps = {
children: React.ReactNode
footer: React.ReactNode
className?: string
isLoading?: boolean
ref?: React.RefObject<HTMLDivElement | null>
}
const CreateResourceCard = ({
children,
footer,
className,
isLoading = false,
ref,
}: CreateResourceCardProps) => {
return (
<div
ref={ref}
className={cn(
'relative col-span-1 inline-flex h-41.5 flex-col overflow-hidden rounded-xl bg-background-default-dimmed transition-opacity',
isLoading && 'pointer-events-none opacity-50',
className,
)}
>
<div className="flex min-h-0 grow flex-col justify-center p-2">
<div className="flex w-full flex-col gap-0.5">
{children}
</div>
</div>
<div className="flex shrink-0 items-center border-t-[0.5px] border-divider-subtle p-2">
{footer}
</div>
</div>
)
}
export default React.memo(CreateResourceCard)

View File

@ -1,17 +0,0 @@
import { render, screen } from '@testing-library/react'
import MixedVariableTextInput from '../index'
describe('MixedVariableTextInput', () => {
it('should render placeholder guidance and data type badge', () => {
render(<MixedVariableTextInput />)
expect(screen.getByText('Type or press')).toBeInTheDocument()
expect(screen.getByText('insert variable')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
})
it('should keep placeholder visible when editor is not editable', () => {
render(<MixedVariableTextInput editable={false} />)
expect(screen.getByText('insert variable')).toBeInTheDocument()
})
})

View File

@ -1,74 +0,0 @@
import type { EditorState } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { $getRoot } from 'lexical'
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
import Placeholder from '../placeholder'
const config = {
namespace: 'placeholder-test',
theme: {},
nodes: [CustomTextNode],
onError: (error: Error) => {
throw error
},
}
describe('MixedVariable Placeholder', () => {
it('should render helper text and insert variable action', () => {
render(
<LexicalComposer initialConfig={config}>
<Placeholder />
</LexicalComposer>,
)
expect(screen.getByText('Type or press')).toBeInTheDocument()
expect(screen.getByText('insert variable')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
})
it('should render shortcut symbol for variable insertion', () => {
render(
<LexicalComposer initialConfig={config}>
<Placeholder />
</LexicalComposer>,
)
expect(screen.getByText('/')).toBeInTheDocument()
})
it('should insert text and keep editor content available after click', async () => {
const user = userEvent.setup()
let editorText = ''
const handleChange = (editorState: EditorState) => {
editorState.read(() => {
editorText = $getRoot().getTextContent()
})
}
render(
<LexicalComposer initialConfig={config}>
<OnChangePlugin onChange={handleChange} />
<Placeholder />
</LexicalComposer>,
)
await user.click(screen.getByText('insert variable'))
expect(editorText).toContain('/')
})
it('should handle container click without breaking the helper UI', async () => {
const user = userEvent.setup()
render(
<LexicalComposer initialConfig={config}>
<Placeholder />
</LexicalComposer>,
)
await user.click(screen.getByText('Type or press'))
expect(screen.getByText('insert variable')).toBeInTheDocument()
})
})

View File

@ -1,39 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
import {
memo,
} from 'react'
import PromptEditor from '@/app/components/base/prompt-editor'
import Placeholder from './placeholder'
type MixedVariableTextInputProps = {
editable?: boolean
value?: string
onChange?: (text: string) => void
}
const MixedVariableTextInput = ({
editable = true,
value = '',
onChange,
}: MixedVariableTextInputProps) => {
return (
<PromptEditor
wrapperClassName={cn(
'rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
)}
className="caret:text-text-accent"
editable={editable}
value={value}
workflowVariableBlock={{
show: true,
variables: [],
workflowNodesMap: {},
}}
placeholder={<Placeholder />}
onChange={onChange}
/>
)
}
export default memo(MixedVariableTextInput)

View File

@ -1,49 +0,0 @@
import { Kbd } from '@langgenius/dify-ui/kbd'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
import { useCallback } from 'react'
import Badge from '@/app/components/base/badge'
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
const Placeholder = () => {
const [editor] = useLexicalComposerContext()
const handleInsert = useCallback((text: string) => {
editor.update(() => {
const textNode = new CustomTextNode(text)
$insertNodes([textNode])
})
editor.dispatchCommand(FOCUS_COMMAND, undefined as any)
}, [editor])
return (
<div
className="pointer-events-auto flex size-full cursor-text items-center px-2"
onClick={(e) => {
e.stopPropagation()
handleInsert('')
}}
>
<div className="flex grow items-center">
Type or press
<Kbd className="mx-0.5 text-text-placeholder">/</Kbd>
<div
className="cursor-pointer system-sm-regular text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary"
onClick={((e) => {
e.stopPropagation()
handleInsert('/')
})}
>
insert variable
</div>
</div>
<Badge
className="shrink-0"
text="String"
uppercase={false}
/>
</div>
)
}
export default Placeholder

View File

@ -1,94 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import BaseForm from '../index'
import { BaseFieldType } from '../types'
const baseConfigurations = [{
type: BaseFieldType.textInput,
variable: 'name',
label: 'Name',
required: false,
showConditions: [],
}]
describe('BaseForm', () => {
it('should render configured fields', () => {
render(
<BaseForm
initialData={{ name: 'Alice' }}
configurations={[...baseConfigurations]}
onSubmit={() => {}}
/>,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument()
})
it('should submit current form values when submit button is clicked', async () => {
const onSubmit = vi.fn()
render(
<BaseForm
initialData={{ name: 'Alice' }}
configurations={[...baseConfigurations]}
onSubmit={onSubmit}
CustomActions={({ form }) => (
<button type="button" onClick={() => form.handleSubmit()}>
Submit
</button>
)}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /submit/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ name: 'Alice' })
})
})
it('should render custom actions when provided', () => {
render(
<BaseForm
initialData={{ name: 'Alice' }}
configurations={[...baseConfigurations]}
onSubmit={() => {}}
CustomActions={() => <button type="button">Save Form</button>}
/>,
)
expect(screen.getByRole('button', { name: /save form/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /common.operation.submit/i })).not.toBeInTheDocument()
})
it('should handle native form submit and block invalid submission', async () => {
const onSubmit = vi.fn()
const requiredConfig = [{
type: BaseFieldType.textInput,
variable: 'name',
label: 'Name',
required: true,
showConditions: [],
maxLength: 2,
}]
const { container } = render(
<BaseForm
initialData={{ name: 'ok' }}
configurations={requiredConfig}
onSubmit={onSubmit}
/>,
)
const form = container.querySelector('form')
const input = screen.getByRole('textbox')
expect(form).not.toBeNull()
fireEvent.submit(form!)
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ name: 'ok' })
})
fireEvent.change(input, { target: { value: 'long' } })
fireEvent.submit(form!)
expect(onSubmit).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,64 +0,0 @@
import type { BaseFormProps } from './types'
import * as React from 'react'
import { useMemo } from 'react'
import { useAppForm } from '../..'
import BaseField from './field'
import { generateZodSchema } from './utils'
const BaseForm = ({
initialData,
configurations,
onSubmit,
CustomActions,
}: BaseFormProps) => {
const schema = useMemo(() => {
const schema = generateZodSchema(configurations)
return schema
}, [configurations])
const baseForm = useAppForm({
defaultValues: initialData,
validators: {
onChange: ({ value }) => {
const result = schema.safeParse(value)
if (!result.success) {
const issues = result.error.issues
const firstIssue = issues[0]!.message
return firstIssue
}
return undefined
},
},
onSubmit: ({ value }) => {
onSubmit(value)
},
})
return (
<form
className="w-full"
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
baseForm.handleSubmit()
}}
>
<div className="flex flex-col gap-4 px-4 py-2">
{configurations.map((config, index) => {
const FieldComponent = BaseField({
initialData,
config,
})
return <FieldComponent key={index} form={baseForm} />
})}
</div>
<baseForm.AppForm>
<baseForm.Actions
CustomActions={CustomActions}
/>
</baseForm.AppForm>
</form>
)
}
export default React.memo(BaseForm)

View File

@ -1,5 +1,4 @@
import type { Option } from '../../components/field/select'
import type { CustomActionsProps } from '../../components/form/actions'
import type { TransferMethod } from '@/types/app'
export enum BaseFieldType {
@ -52,10 +51,3 @@ export type BaseConfiguration = {
} & NumberConfiguration
& Partial<SelectConfiguration>
& Partial<FileConfiguration>
export type BaseFormProps = {
initialData?: Record<string, any>
configurations: BaseConfiguration[]
CustomActions?: (props: CustomActionsProps) => React.ReactNode
onSubmit: (value: Record<string, any>) => void
}

View File

@ -1,24 +0,0 @@
import { render, screen } from '@testing-library/react'
import { useAppForm } from '../../..'
import ContactFields from '../contact-fields'
import { demoFormOpts } from '../shared-options'
const ContactFieldsHarness = () => {
const form = useAppForm({
...demoFormOpts,
onSubmit: () => {},
})
return <ContactFields form={form} />
}
describe('ContactFields', () => {
it('should render contact section fields', () => {
render(<ContactFieldsHarness />)
expect(screen.getByRole('heading', { name: /contacts/i })).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: /phone/i })).toBeInTheDocument()
expect(screen.getByText(/preferred contact method/i)).toBeInTheDocument()
})
})

View File

@ -1,69 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import DemoForm from '../index'
describe('DemoForm', () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the primary fields', () => {
render(<DemoForm />)
expect(screen.getByRole('textbox', { name: /^name$/i })).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: /^surname$/i })).toBeInTheDocument()
expect(screen.getByText(/i accept the terms and conditions/i)).toBeInTheDocument()
})
it('should show contact fields after a name is entered', () => {
render(<DemoForm />)
expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox', { name: /^name$/i }), { target: { value: 'Alice' } })
expect(screen.getByRole('heading', { name: /contacts/i })).toBeInTheDocument()
})
it('should hide contact fields when name is cleared', () => {
render(<DemoForm />)
const nameInput = screen.getByRole('textbox', { name: /^name$/i })
fireEvent.change(nameInput, { target: { value: 'Alice' } })
expect(screen.getByRole('heading', { name: /contacts/i })).toBeInTheDocument()
fireEvent.change(nameInput, { target: { value: '' } })
expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument()
})
it('should log validation errors on invalid submit', () => {
render(<DemoForm />)
const nameInput = screen.getByRole('textbox', { name: /^name$/i }) as HTMLInputElement
fireEvent.submit(nameInput.form!)
return waitFor(() => {
expect(consoleLogSpy).toHaveBeenCalledWith('Validation errors:', expect.any(Array))
})
})
it('should log submitted values on valid submit', () => {
render(<DemoForm />)
const nameInput = screen.getByRole('textbox', { name: /^name$/i }) as HTMLInputElement
fireEvent.change(nameInput, { target: { value: 'Alice' } })
fireEvent.change(screen.getByRole('textbox', { name: /^surname$/i }), { target: { value: 'Smith' } })
fireEvent.click(screen.getByText(/i accept the terms and conditions/i))
fireEvent.change(screen.getByRole('textbox', { name: /email/i }), { target: { value: 'alice@example.com' } })
fireEvent.submit(nameInput.form!)
return waitFor(() => {
expect(consoleLogSpy).toHaveBeenCalledWith('Form submitted:', expect.objectContaining({
name: 'Alice',
surname: 'Smith',
isAcceptingTerms: true,
}))
})
})
})

View File

@ -1,16 +0,0 @@
import { demoFormOpts } from '../shared-options'
describe('demoFormOpts', () => {
it('should provide expected default values', () => {
expect(demoFormOpts.defaultValues).toEqual({
name: '',
surname: '',
isAcceptingTerms: false,
contact: {
email: '',
phone: '',
preferredContactMethod: 'email',
},
})
})
})

View File

@ -1,39 +0,0 @@
import { ContactMethods, UserSchema } from '../types'
describe('demo scenario types', () => {
it('should expose contact methods with capitalized labels', () => {
expect(ContactMethods).toEqual([
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'whatsapp', label: 'Whatsapp' },
{ value: 'sms', label: 'Sms' },
])
})
it('should validate a complete user payload', () => {
expect(UserSchema.safeParse({
name: 'Alice',
surname: 'Smith',
isAcceptingTerms: true,
contact: {
email: 'alice@example.com',
phone: '',
preferredContactMethod: 'email',
},
}).success).toBe(true)
})
it('should reject invalid user payload', () => {
const result = UserSchema.safeParse({
name: 'alice',
surname: 's',
isAcceptingTerms: false,
contact: {
email: 'invalid',
preferredContactMethod: 'email',
},
})
expect(result.success).toBe(false)
})
})

View File

@ -1,35 +0,0 @@
import { withForm } from '../..'
import { demoFormOpts } from './shared-options'
import { ContactMethods } from './types'
const ContactFields = withForm({
...demoFormOpts,
render: ({ form }) => {
return (
<div className="my-2">
<h3 className="title-lg-bold text-text-primary">Contacts</h3>
<div className="flex flex-col gap-4">
<form.AppField
name="contact.email"
children={field => <field.TextField label="Email" />}
/>
<form.AppField
name="contact.phone"
children={field => <field.TextField label="Phone" />}
/>
<form.AppField
name="contact.preferredContactMethod"
children={field => (
<field.SelectField
label="Preferred Contact Method"
options={ContactMethods}
/>
)}
/>
</div>
</div>
)
},
})
export default ContactFields

View File

@ -1,68 +0,0 @@
import { useStore } from '@tanstack/react-form'
import { useAppForm } from '../..'
import ContactFields from './contact-fields'
import { demoFormOpts } from './shared-options'
import { UserSchema } from './types'
const DemoForm = () => {
const form = useAppForm({
...demoFormOpts,
validators: {
onSubmit: ({ value }) => {
// Validate the entire form
const result = UserSchema.safeParse(value)
if (!result.success) {
const issues = result.error.issues
console.log('Validation errors:', issues)
return issues[0]!.message
}
return undefined
},
},
onSubmit: ({ value }) => {
console.log('Form submitted:', value)
},
})
const name = useStore(form.store, state => state.values.name)
return (
<form
className="flex w-[400px] flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="name"
children={field => (
<field.TextField label="Name" />
)}
/>
<form.AppField
name="surname"
children={field => (
<field.TextField label="Surname" />
)}
/>
<form.AppField
name="isAcceptingTerms"
children={field => (
<field.CheckboxField label="I accept the terms and conditions." />
)}
/>
{
!!name && (
<ContactFields form={form} />
)
}
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
}
export default DemoForm

View File

@ -1,14 +0,0 @@
import { formOptions } from '@tanstack/react-form'
export const demoFormOpts = formOptions({
defaultValues: {
name: '',
surname: '',
isAcceptingTerms: false,
contact: {
email: '',
phone: '',
preferredContactMethod: 'email',
},
},
})

View File

@ -1,32 +0,0 @@
import * as z from 'zod'
const ContactMethod = z.union([
z.literal('email'),
z.literal('phone'),
z.literal('whatsapp'),
z.literal('sms'),
])
export const ContactMethods = ContactMethod.options.map(({ value }) => ({
value,
label: value.charAt(0).toUpperCase() + value.slice(1),
}))
export const UserSchema = z.object({
name: z
.string()
.regex(/^[A-Z]/, 'Name must start with a capital letter')
.min(3, 'Name must be at least 3 characters long'),
surname: z
.string()
.min(3, 'Surname must be at least 3 characters long')
.regex(/^[A-Z]/, 'Surname must start with a capital letter'),
isAcceptingTerms: z.boolean().refine(val => val, {
error: 'You must accept the terms and conditions',
}),
contact: z.object({
email: z.email('Invalid email address'),
phone: z.string().optional(),
preferredContactMethod: ContactMethod,
}),
})

View File

@ -1,178 +0,0 @@
import { InputFieldType } from '../types'
import { generateZodSchema } from '../utils'
describe('input-field scenario schema generator', () => {
it('should validate required text input with max length', () => {
const schema = generateZodSchema([{
type: InputFieldType.textInput,
variable: 'prompt',
label: 'Prompt',
required: true,
maxLength: 5,
showConditions: [],
}])
expect(schema.safeParse({ prompt: 'hello' }).success).toBe(true)
expect(schema.safeParse({ prompt: '' }).success).toBe(false)
expect(schema.safeParse({ prompt: 'longer than five' }).success).toBe(false)
})
it('should validate file types payload shape', () => {
const schema = generateZodSchema([{
type: InputFieldType.fileTypes,
variable: 'files',
label: 'Files',
required: true,
showConditions: [],
}])
expect(schema.safeParse({
files: {
allowedFileExtensions: 'txt,pdf',
allowedFileTypes: ['document'],
},
}).success).toBe(true)
expect(schema.safeParse({
files: {
allowedFileTypes: ['invalid-type'],
},
}).success).toBe(false)
})
it('should allow optional upload method fields to be omitted', () => {
const schema = generateZodSchema([{
type: InputFieldType.uploadMethod,
variable: 'methods',
label: 'Methods',
required: false,
showConditions: [],
}])
expect(schema.safeParse({}).success).toBe(true)
})
it('should validate numeric bounds and other field type shapes', () => {
const schema = generateZodSchema([
{
type: InputFieldType.numberInput,
variable: 'count',
label: 'Count',
required: true,
min: 1,
max: 3,
showConditions: [],
},
{
type: InputFieldType.numberSlider,
variable: 'temperature',
label: 'Temperature',
required: true,
showConditions: [],
},
{
type: InputFieldType.checkbox,
variable: 'enabled',
label: 'Enabled',
required: true,
showConditions: [],
},
{
type: InputFieldType.options,
variable: 'choices',
label: 'Choices',
required: true,
showConditions: [],
},
{
type: InputFieldType.select,
variable: 'mode',
label: 'Mode',
required: true,
showConditions: [],
},
{
type: InputFieldType.inputTypeSelect,
variable: 'inputType',
label: 'Input Type',
required: true,
showConditions: [],
},
{
type: InputFieldType.uploadMethod,
variable: 'methods',
label: 'Methods',
required: true,
showConditions: [],
},
{
type: 'unsupported' as InputFieldType,
variable: 'other',
label: 'Other',
required: true,
showConditions: [],
},
])
expect(schema.safeParse({
count: 2,
temperature: 0.5,
enabled: true,
choices: ['a'],
mode: 'safe',
inputType: 'text',
methods: ['local_file'],
other: { key: 'value' },
}).success).toBe(true)
expect(schema.safeParse({
count: 0,
temperature: 0.5,
enabled: true,
choices: ['a'],
mode: 'safe',
inputType: 'text',
methods: ['local_file'],
other: { key: 'value' },
}).success).toBe(false)
expect(schema.safeParse({
count: 4,
temperature: 0.5,
enabled: true,
choices: ['a'],
mode: 'safe',
inputType: 'text',
methods: ['local_file'],
other: { key: 'value' },
}).success).toBe(false)
})
it('should ignore constraints for irrelevant field types', () => {
const schema = generateZodSchema([
{
type: InputFieldType.numberInput,
variable: 'num',
label: 'Num',
required: true,
maxLength: 10, // maxLength is for textInput, should be ignored
showConditions: [],
},
{
type: InputFieldType.textInput,
variable: 'text',
label: 'Text',
required: true,
min: 1, // min is for numberInput, should be ignored
max: 5, // max is for numberInput, should be ignored
showConditions: [],
},
])
// Should still work based on their base types
// num: 12345678901 (violates maxLength: 10 if it were applied)
// text: 'long string here' (violates max: 5 if it were applied)
expect(schema.safeParse({ num: 12345678901, text: 'long string here' }).success).toBe(true)
expect(schema.safeParse({ num: 'not a number', text: 'hello' }).success).toBe(false)
})
})

View File

@ -1,76 +0,0 @@
import type { ZodSchema, ZodString } from 'zod'
import type { InputFieldConfiguration } from './types'
import * as z from 'zod'
import { SupportedFileTypes, TransferMethod } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/schema'
import { InputFieldType } from './types'
export const generateZodSchema = (fields: InputFieldConfiguration[]) => {
const shape: Record<string, ZodSchema> = {}
fields.forEach((field) => {
let zodType
switch (field.type) {
case InputFieldType.textInput:
zodType = z.string()
break
case InputFieldType.numberInput:
zodType = z.number()
break
case InputFieldType.numberSlider:
zodType = z.number()
break
case InputFieldType.checkbox:
zodType = z.boolean()
break
case InputFieldType.options:
zodType = z.array(z.string())
break
case InputFieldType.select:
zodType = z.string()
break
case InputFieldType.fileTypes:
zodType = z.object({
allowedFileExtensions: z.string().optional(),
allowedFileTypes: z.array(SupportedFileTypes),
})
break
case InputFieldType.inputTypeSelect:
zodType = z.string()
break
case InputFieldType.uploadMethod:
zodType = z.array(TransferMethod)
break
default:
zodType = z.any()
break
}
if (field.maxLength) {
if ([InputFieldType.textInput].includes(field.type))
zodType = (zodType as ZodString).max(field.maxLength, `${field.label} exceeds max length of ${field.maxLength}`)
}
if (field.min) {
if ([InputFieldType.numberInput].includes(field.type))
zodType = (zodType as ZodString).min(field.min, `${field.label} must be at least ${field.min}`)
}
if (field.max) {
if ([InputFieldType.numberInput].includes(field.type))
zodType = (zodType as ZodString).max(field.max, `${field.label} exceeds max value of ${field.max}`)
}
if (field.required) {
if ([InputFieldType.textInput].includes(field.type))
zodType = (zodType as ZodString).nonempty(`${field.label} is required`)
}
else {
zodType = zodType.optional()
}
shape[field.variable] = zodType
})
return z.object(shape)
}

View File

@ -1,151 +0,0 @@
import type { ReactNode } from 'react'
import type { InputFieldConfiguration } from '../types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { useMemo } from 'react'
import { ReactFlowProvider } from 'reactflow'
import { useAppForm } from '../../..'
import NodePanelField from '../field'
import { InputFieldType } from '../types'
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: () => <div>Variable Picker</div>,
}))
const createConfig = (overrides: Partial<InputFieldConfiguration> = {}): InputFieldConfiguration => ({
type: InputFieldType.textInput,
variable: 'fieldA',
label: 'Field A',
required: false,
showConditions: [],
...overrides,
})
type FieldHarnessProps = {
config: InputFieldConfiguration
initialData?: Record<string, unknown>
}
const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => {
const form = useAppForm({
defaultValues: initialData,
onSubmit: () => {},
})
const Component = useMemo(() => NodePanelField({ initialData, config }), [config, initialData])
return <Component form={form} />
}
const NodePanelWrapper = ({ children }: { children: ReactNode }) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return (
<QueryClientProvider client={queryClient}>
<ReactFlowProvider>
{children}
</ReactFlowProvider>
</QueryClientProvider>
)
}
const getVisibleText = (text: string) => {
const element = screen.getAllByText(text).find(element => !element.classList.contains('sr-only'))
expect(element).toBeDefined()
return element!
}
describe('NodePanelField', () => {
it('should render text input field', () => {
render(<FieldHarness config={createConfig({ label: 'Node Name' })} initialData={{ fieldA: '' }} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('Node Name')).toBeInTheDocument()
})
it('should render variable-or-constant field when configured', () => {
render(
<NodePanelWrapper>
<FieldHarness
config={createConfig({
type: InputFieldType.variableOrConstant,
label: 'Mode',
})}
initialData={{ fieldA: '' }}
/>
</NodePanelWrapper>,
)
expect(screen.getByText('Mode')).toBeInTheDocument()
})
it('should hide field when show conditions are not satisfied', () => {
render(
<FieldHarness
config={createConfig({
label: 'Hidden Node Field',
showConditions: [{ variable: 'enabled', value: true }],
})}
initialData={{ enabled: false, fieldA: '' }}
/>,
)
expect(screen.queryByText('Hidden Node Field')).not.toBeInTheDocument()
})
it('should render other configured field types and hide unsupported type', () => {
const scenarios: Array<{ config: InputFieldConfiguration, initialData: Record<string, unknown> }> = [
{
config: createConfig({ type: InputFieldType.numberInput, label: 'Count', min: 1, max: 3 }),
initialData: { fieldA: 2 },
},
{
config: createConfig({ type: InputFieldType.numberSlider, label: 'Temperature', description: 'Adjust' }),
initialData: { fieldA: 0.4 },
},
{
config: createConfig({ type: InputFieldType.checkbox, label: 'Enabled' }),
initialData: { fieldA: true },
},
{
config: createConfig({ type: InputFieldType.select, label: 'Mode', options: [{ value: 'safe', label: 'Safe' }] }),
initialData: { fieldA: 'safe' },
},
{
config: createConfig({ type: InputFieldType.inputTypeSelect, label: 'Input Type', supportFile: true }),
initialData: { fieldA: 'text' },
},
{
config: createConfig({ type: InputFieldType.uploadMethod, label: 'Upload Method' }),
initialData: { fieldA: ['local_file'] },
},
{
config: createConfig({ type: InputFieldType.fileTypes, label: 'File Types' }),
initialData: { fieldA: { allowedFileTypes: ['document'] } },
},
{
config: createConfig({ type: InputFieldType.options, label: 'Options' }),
initialData: { fieldA: ['a'] },
},
]
for (const scenario of scenarios) {
const { unmount } = render(<FieldHarness config={scenario.config} initialData={scenario.initialData} />)
expect(getVisibleText(scenario.config.label)).toBeInTheDocument()
unmount()
}
render(
<FieldHarness
config={createConfig({ type: 'unsupported' as InputFieldType, label: 'Unsupported Node' })}
initialData={{ fieldA: '' }}
/>,
)
expect(screen.queryByText('Unsupported Node')).not.toBeInTheDocument()
})
})

View File

@ -1,7 +0,0 @@
import { InputFieldType } from '../types'
describe('node-panel scenario types', () => {
it('should include variableOrConstant field type', () => {
expect(Object.values(InputFieldType)).toContain('variableOrConstant')
})
})

View File

@ -1,240 +0,0 @@
import type { InputFieldConfiguration } from './types'
import { useStore } from '@tanstack/react-form'
import * as React from 'react'
import { withForm } from '../..'
import { InputFieldType } from './types'
type InputFieldProps = {
initialData?: Record<string, any>
config: InputFieldConfiguration
}
const NodePanelField = ({
initialData,
config,
}: InputFieldProps) => withForm({
defaultValues: initialData,
render: function Render({
form,
}) {
const {
type,
label,
placeholder,
variable,
tooltip,
showConditions,
max,
min,
required,
showOptional,
supportFile,
description,
options,
listeners,
popupProps,
} = config
const isAllConditionsMet = useStore(form.store, (state) => {
const fieldValues = state.values
return showConditions.every((condition) => {
const { variable, value } = condition
const fieldValue = fieldValues[variable as keyof typeof fieldValues]
return fieldValue === value
})
})
if (!isAllConditionsMet)
return <></>
if (type === InputFieldType.textInput) {
return (
<form.AppField
name={variable}
children={field => (
<field.TextField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
/>
)}
/>
)
}
if (type === InputFieldType.numberInput) {
return (
<form.AppField
name={variable}
children={field => (
<field.NumberInputField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
max={max}
min={min}
/>
)}
/>
)
}
if (type === InputFieldType.numberSlider) {
return (
<form.AppField
name={variable}
children={field => (
<field.NumberSliderField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
description={description}
max={max}
min={min}
/>
)}
/>
)
}
if (type === InputFieldType.checkbox) {
return (
<form.AppField
name={variable}
children={field => (
<field.CheckboxField
label={label}
/>
)}
/>
)
}
if (type === InputFieldType.select) {
return (
<form.AppField
name={variable}
children={field => (
<field.SelectField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
options={options!}
popupProps={popupProps}
/>
)}
/>
)
}
if (type === InputFieldType.inputTypeSelect) {
return (
<form.AppField
name={variable}
listeners={listeners}
children={field => (
<field.InputTypeSelectField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
supportFile={!!supportFile}
/>
)}
/>
)
}
if (type === InputFieldType.uploadMethod) {
return (
<form.AppField
name={variable}
children={field => (
<field.UploadMethodField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
if (type === InputFieldType.fileTypes) {
return (
<form.AppField
name={variable}
children={field => (
<field.FileTypesField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
if (type === InputFieldType.options) {
return (
<form.AppField
name={variable}
children={field => (
<field.OptionsField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
if (type === InputFieldType.variableOrConstant) {
return (
<form.AppField
name={variable}
children={field => (
<field.VariableOrConstantInputField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
return <></>
},
})
export default NodePanelField

View File

@ -1,40 +0,0 @@
import type { DeepKeys, FieldListeners } from '@tanstack/react-form'
import type { NumberConfiguration, SelectConfiguration, ShowCondition } from '../base/types'
export enum InputFieldType {
textInput = 'textInput',
numberInput = 'numberInput',
numberSlider = 'numberSlider',
checkbox = 'checkbox',
options = 'options',
select = 'select',
inputTypeSelect = 'inputTypeSelect',
uploadMethod = 'uploadMethod',
fileTypes = 'fileTypes',
variableOrConstant = 'variableOrConstant',
}
type InputTypeSelectConfiguration = {
supportFile: boolean
}
type NumberSliderConfiguration = {
description: string
max?: number
min?: number
}
export type InputFieldConfiguration = {
label: string
variable: string // Variable name
maxLength?: number // Max length for text input
placeholder?: string
required: boolean
showOptional?: boolean // show optional label
showConditions: ShowCondition[] // Show this field only when all conditions are met
type: InputFieldType
tooltip?: string // Tooltip for this field
listeners?: FieldListeners<Record<string, any>, DeepKeys<Record<string, any>>> // Listener for this field
} & NumberConfiguration & Partial<InputTypeSelectConfiguration>
& Partial<NumberSliderConfiguration>
& Partial<SelectConfiguration>

View File

@ -1,560 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { FormStoryRender } from '../../../../.storybook/utils/form-story-wrapper'
import type { FormSchema } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { useStore } from '@tanstack/react-form'
import { useMemo, useState } from 'react'
import { PreviewMode } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
import { FormStoryWrapper } from '../../../../.storybook/utils/form-story-wrapper'
import BaseForm from './components/base/base-form'
import ContactFields from './form-scenarios/demo/contact-fields'
import { demoFormOpts } from './form-scenarios/demo/shared-options'
import { ContactMethods, UserSchema } from './form-scenarios/demo/types'
import { FormTypeEnum } from './types'
const FormStoryHost = () => null
const meta = {
title: 'Base/Data Entry/AppForm',
component: FormStoryHost,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Helper utilities built on top of `@tanstack/react-form` that power form rendering across Dify. These stories demonstrate the `useAppForm` hook, field primitives, conditional visibility, and custom actions.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof FormStoryHost>
export default meta
type Story = StoryObj<typeof meta>
type AppFormInstance = Parameters<FormStoryRender>[0]
type ContactFieldsProps = React.ComponentProps<typeof ContactFields>
type ContactFieldsFormApi = ContactFieldsProps['form']
type PlaygroundFormFieldsProps = {
form: AppFormInstance
status: string
}
const PlaygroundFormFields = ({ form, status }: PlaygroundFormFieldsProps) => {
type PlaygroundFormValues = typeof demoFormOpts.defaultValues
const name = useStore(form.store, state => (state.values as PlaygroundFormValues).name)
const contactFormApi = form as unknown as ContactFieldsFormApi
return (
<form
className="flex w-full max-w-xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="name"
children={field => (
<field.TextField
label="Name"
placeholder="Start with a capital letter"
/>
)}
/>
<form.AppField
name="surname"
children={field => (
<field.TextField
label="Surname"
placeholder="Surname must be at least 3 characters"
/>
)}
/>
<form.AppField
name="isAcceptingTerms"
children={field => (
<field.CheckboxField
label="I accept the terms and conditions"
/>
)}
/>
{!!name && <ContactFields form={contactFormApi} />}
<form.AppForm>
<form.Actions />
</form.AppForm>
<p className="text-xs text-text-tertiary">{status}</p>
</form>
)
}
const FormPlayground = () => {
const [status, setStatus] = useState('Fill in the form and submit to see results.')
return (
<FormStoryWrapper
title="Customer onboarding form"
subtitle="Validates with zod and conditionally reveals contact preferences."
options={{
...demoFormOpts,
validators: {
onSubmit: ({ value: formValue }) => {
const result = UserSchema.safeParse(formValue as typeof demoFormOpts.defaultValues)
if (!result.success)
return result.error.issues[0]!.message
return undefined
},
},
onSubmit: () => {
setStatus('Successfully saved profile.')
},
}}
>
{form => <PlaygroundFormFields form={form} status={status} />}
</FormStoryWrapper>
)
}
const mockFileUploadConfig = {
enabled: true,
allowed_file_extensions: ['pdf', 'png'],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
preview_config: {
mode: PreviewMode.CurrentPage,
file_type_list: ['pdf', 'png'],
},
}
const mockFieldDefaults = {
headline: 'Dify App',
description: 'Streamline your AI workflows with configurable building blocks.',
category: 'workbench',
allowNotifications: true,
dailyLimit: 40,
attachment: [],
}
const FieldGallery = () => {
const selectOptions = useMemo(() => [
{ value: 'workbench', label: 'Workbench' },
{ value: 'playground', label: 'Playground' },
{ value: 'production', label: 'Production' },
], [])
return (
<FormStoryWrapper
title="Field gallery"
subtitle="Preview the most common field primitives exposed through `form.AppField` helpers."
options={{
defaultValues: mockFieldDefaults,
}}
>
{form => (
<form
className="grid w-full max-w-4xl grid-cols-1 gap-4 lg:grid-cols-2"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="headline"
children={field => (
<field.TextField
label="Headline"
placeholder="Name your experience"
/>
)}
/>
<form.AppField
name="description"
children={field => (
<field.TextAreaField
label="Description"
placeholder="Describe what this configuration does"
/>
)}
/>
<form.AppField
name="category"
children={field => (
<field.SelectField
label="Category"
options={selectOptions}
/>
)}
/>
<form.AppField
name="allowNotifications"
children={field => (
<field.CheckboxField label="Enable usage notifications" />
)}
/>
<form.AppField
name="dailyLimit"
children={field => (
<field.NumberSliderField
label="Daily session limit"
description="Control the maximum number of runs per user each day."
min={10}
max={100}
/>
)}
/>
<form.AppField
name="attachment"
children={field => (
<field.FileUploaderField
label="Reference materials"
fileConfig={mockFileUploadConfig}
/>
)}
/>
<div className="lg:col-span-2">
<form.AppForm>
<form.Actions />
</form.AppForm>
</div>
</form>
)}
</FormStoryWrapper>
)
}
const conditionalSchemas: FormSchema[] = [
{
type: FormTypeEnum.select,
name: 'channel',
label: 'Preferred channel',
required: true,
default: 'email',
options: ContactMethods,
},
{
type: FormTypeEnum.textInput,
name: 'contactEmail',
label: 'Email address',
required: true,
placeholder: 'user@example.com',
show_on: [{ variable: 'channel', value: 'email' }],
},
{
type: FormTypeEnum.textInput,
name: 'contactPhone',
label: 'Phone number',
required: true,
placeholder: '+1 555 123 4567',
show_on: [{ variable: 'channel', value: 'phone' }],
},
{
type: FormTypeEnum.boolean,
name: 'optIn',
label: 'Opt in to marketing messages',
required: false,
},
]
const ConditionalFieldsStory = () => {
const [values, setValues] = useState<Record<string, unknown>>({
channel: 'email',
optIn: false,
})
return (
<div className="flex flex-col gap-6 px-6 md:flex-row md:px-10">
<div className="flex-1 rounded-xl border border-divider-subtle bg-components-panel-bg p-5 shadow-sm">
<BaseForm
formSchemas={conditionalSchemas}
defaultValues={values}
formClassName="flex flex-col gap-4"
onChange={(field, value) => {
setValues(prev => ({
...prev,
[field]: value,
}))
}}
/>
</div>
<aside className="w-full max-w-sm rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary shadow-sm">
<h3 className="text-sm font-semibold text-text-primary">Live values</h3>
<p className="mb-2 text-[11px] text-text-tertiary">`show_on` rules hide or reveal inputs without losing track of the form state.</p>
<pre className="max-h-48 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
{JSON.stringify(values, null, 2)}
</pre>
</aside>
</div>
)
}
const CustomActionsStory = () => {
return (
<FormStoryWrapper
title="Custom footer actions"
subtitle="Override the default submit button to add reset or secondary operations."
options={{
defaultValues: {
datasetName: 'Support FAQ',
datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.',
},
validators: {
onChange: ({ value }) => {
const nextValues = value as { datasetName?: string }
if (!nextValues.datasetName || nextValues.datasetName.length < 3)
return 'Dataset name must contain at least 3 characters.'
return undefined
},
},
}}
>
{form => (
<form
className="flex w-full max-w-xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="datasetName"
children={field => (
<field.TextField
label="Dataset name"
placeholder="Support knowledge base"
/>
)}
/>
<form.AppField
name="datasetDescription"
children={field => (
<field.TextAreaField
label="Description"
placeholder="Add a helpful summary for collaborators"
/>
)}
/>
<form.AppForm>
<form.Actions
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={() => appForm.reset()}
disabled={isSubmitting}
>
Reset
</Button>
<Button
variant="tertiary"
onClick={() => {
appForm.handleSubmit()
}}
disabled={!canSubmit}
loading={isSubmitting}
>
Save draft
</Button>
<Button
variant="primary"
onClick={() => appForm.handleSubmit()}
disabled={!canSubmit}
loading={isSubmitting}
>
Publish
</Button>
</div>
)}
/>
</form.AppForm>
</form>
)}
</FormStoryWrapper>
)
}
export const Playground: Story = {
render: () => <FormPlayground />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const form = useAppForm({
...demoFormOpts,
validators: {
onSubmit: ({ value }) => UserSchema.safeParse(value).success ? undefined : 'Validation failed',
},
onSubmit: ({ value }) => {
setStatus(\`Successfully saved profile for \${value.name}\`)
},
})
return (
<form onSubmit={handleSubmit}>
<form.AppField name="name">
{field => <field.TextField label="Name" placeholder="Start with a capital letter" />}
</form.AppField>
<form.AppField name="surname">
{field => <field.TextField label="Surname" />}
</form.AppField>
<form.AppField name="isAcceptingTerms">
{field => <field.CheckboxField label="I accept the terms and conditions" />}
</form.AppField>
{!!form.store.state.values.name && <ContactFields form={form} />}
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}
export const FieldExplorer: Story = {
render: () => <FieldGallery />,
parameters: {
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/form',
params: { appId: 'demo-app' },
},
},
docs: {
source: {
language: 'tsx',
code: `
const form = useAppForm({
defaultValues: {
headline: 'Dify App',
description: 'Streamline your AI workflows',
category: 'workbench',
allowNotifications: true,
dailyLimit: 40,
attachment: [],
},
})
return (
<form className="grid grid-cols-1 gap-4 lg:grid-cols-2" onSubmit={handleSubmit}>
<form.AppField name="headline">
{field => <field.TextField label="Headline" />}
</form.AppField>
<form.AppField name="description">
{field => <field.TextAreaField label="Description" />}
</form.AppField>
<form.AppField name="category">
{field => <field.SelectField label="Category" options={selectOptions} />}
</form.AppField>
<form.AppField name="allowNotifications">
{field => <field.CheckboxField label="Enable usage notifications" />}
</form.AppField>
<form.AppField name="dailyLimit">
{field => <field.NumberSliderField label="Daily session limit" min={10} max={100} step={10} />}
</form.AppField>
<form.AppField name="attachment">
{field => <field.FileUploaderField label="Reference materials" fileConfig={mockFileUploadConfig} />}
</form.AppField>
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}
export const ConditionalVisibility: Story = {
render: () => <ConditionalFieldsStory />,
parameters: {
docs: {
description: {
story: 'Demonstrates schema-driven visibility using `show_on` conditions rendered through the reusable `BaseForm` component.',
},
source: {
language: 'tsx',
code: `
const conditionalSchemas: FormSchema[] = [
{ type: FormTypeEnum.select, name: 'channel', label: 'Preferred channel', options: ContactMethods },
{ type: FormTypeEnum.textInput, name: 'contactEmail', label: 'Email', show_on: [{ variable: 'channel', value: 'email' }] },
{ type: FormTypeEnum.textInput, name: 'contactPhone', label: 'Phone', show_on: [{ variable: 'channel', value: 'phone' }] },
{ type: FormTypeEnum.boolean, name: 'optIn', label: 'Opt in to marketing messages' },
]
return (
<BaseForm
formSchemas={conditionalSchemas}
defaultValues={{ channel: 'email', optIn: false }}
formClassName="flex flex-col gap-4"
onChange={(field, value) => setValues(prev => ({ ...prev, [field]: value }))}
/>
)
`.trim(),
},
},
},
}
export const CustomActions: Story = {
render: () => <CustomActionsStory />,
parameters: {
docs: {
description: {
story: 'Shows how to replace the default submit button with a fully custom footer leveraging contextual form state.',
},
source: {
language: 'tsx',
code: `
const form = useAppForm({
defaultValues: {
datasetName: 'Support FAQ',
datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.',
},
validators: {
onChange: ({ value }) => value.datasetName?.length >= 3 ? undefined : 'Dataset name must contain at least 3 characters.',
},
})
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<form.AppField name="datasetName">
{field => <field.TextField label="Dataset name" />}
</form.AppField>
<form.AppField name="datasetDescription">
{field => <field.TextAreaField label="Description" />}
</form.AppField>
<form.AppForm>
<form.Actions
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={() => appForm.reset()} disabled={isSubmitting}>
Reset
</Button>
<Button variant="tertiary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
Save draft
</Button>
<Button variant="primary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
Publish
</Button>
</div>
)}
/>
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}

View File

@ -1,5 +0,0 @@
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/baichuan-text-cn.png) center center no-repeat;
background-size: contain;
}

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './BaichuanTextCn.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = 'BaichuanTextCn'
export default Icon

View File

@ -1,5 +0,0 @@
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/minimax.png) center center no-repeat;
background-size: contain;
}

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './Minimax.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = 'Minimax'
export default Icon

View File

@ -1,5 +0,0 @@
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/minimax-text.png) center center no-repeat;
background-size: contain;
}

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './MinimaxText.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = 'MinimaxText'
export default Icon

View File

@ -1,5 +0,0 @@
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/tongyi.png) center center no-repeat;
background-size: contain;
}

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './Tongyi.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = 'Tongyi'
export default Icon

View File

@ -1,5 +0,0 @@
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/tongyi-text.png) center center no-repeat;
background-size: contain;
}

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './TongyiText.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = 'TongyiText'
export default Icon

View File

@ -1,5 +0,0 @@
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/tongyi-text-cn.png) center center no-repeat;
background-size: contain;
}

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './TongyiTextCn.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = 'TongyiTextCn'
export default Icon

View File

@ -1,5 +0,0 @@
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/wxyy.png) center center no-repeat;
background-size: contain;
}

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './Wxyy.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = 'Wxyy'
export default Icon

View File

@ -1,5 +0,0 @@
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/wxyy-text.png) center center no-repeat;
background-size: contain;
}

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './WxyyText.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = 'WxyyText'
export default Icon

View File

@ -1,5 +0,0 @@
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/wxyy-text-cn.png) center center no-repeat;
background-size: contain;
}

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import s from './WxyyTextCn.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = 'WxyyTextCn'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './ArCube1.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'ArCube1'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Asterisk.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Asterisk'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Buildings.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Buildings'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Diamond.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Diamond'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Group2.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Group2'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Keyframe.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Keyframe'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Sparkles.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Sparkles'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './SparklesSoft.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'SparklesSoft'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './D.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'D'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './DiagonalDividingLine.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'DiagonalDividingLine'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Dify.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Dify'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Lock.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Lock'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './MessageChatSquare.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'MessageChatSquare'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './MultiPathRetrieval.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'MultiPathRetrieval'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './NTo1Retrieval.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'NTo1Retrieval'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './File.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'File'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Watercrawl.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Watercrawl'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Graph.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Graph'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Anthropic.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Anthropic'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './AnthropicText.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'AnthropicText'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './AzureOpenaiService.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'AzureOpenaiService'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './AzureOpenaiServiceText.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'AzureOpenaiServiceText'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Azureai.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Azureai'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './AzureaiText.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'AzureaiText'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Baichuan.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Baichuan'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './BaichuanText.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'BaichuanText'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Chatglm.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Chatglm'
export default Icon

View File

@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './ChatglmText.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'ChatglmText'
export default Icon

Some files were not shown because too many files have changed in this diff Show More