mirror of
https://github.com/langgenius/dify.git
synced 2026-05-08 20:08:36 +08:00
Merge branch 'main' into 4-27-app-deploy
This commit is contained in:
commit
2212384a35
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: frontend-query-mutation
|
||||
description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions() directly or extract a helper or use-* hook, handling conditional queries, cache invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers.
|
||||
description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions()/mutationOptions() directly or extract a helper or use-* hook, configuring oRPC experimental_defaults/default options, handling conditional queries, cache updates/invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers.
|
||||
---
|
||||
|
||||
# Frontend Query & Mutation
|
||||
@ -9,22 +9,24 @@ description: Guide for implementing Dify frontend query and mutation patterns wi
|
||||
|
||||
- Keep contract as the single source of truth in `web/contract/*`.
|
||||
- Prefer contract-shaped `queryOptions()` and `mutationOptions()`.
|
||||
- Keep invalidation and mutation flow knowledge in the service layer.
|
||||
- Keep default cache behavior with `consoleQuery`/`marketplaceQuery` setup, and keep business orchestration in feature vertical hooks when direct contract calls are not enough.
|
||||
- Treat `web/service/use-*` query or mutation wrappers as legacy migration targets, not the preferred destination.
|
||||
- Keep abstractions minimal to preserve TypeScript inference.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the change surface.
|
||||
- Read `references/contract-patterns.md` for contract files, router composition, client helpers, and query or mutation call-site shape.
|
||||
- Read `references/runtime-rules.md` for conditional queries, invalidation, error handling, and legacy migrations.
|
||||
- Read `references/runtime-rules.md` for conditional queries, default options, cache updates/invalidation, error handling, and legacy migrations.
|
||||
- Read both references when a task spans contract shape and runtime behavior.
|
||||
2. Implement the smallest abstraction that fits the task.
|
||||
- Default to direct `useQuery(...)` or `useMutation(...)` calls with oRPC helpers at the call site.
|
||||
- Extract a small shared query helper only when multiple call sites share the same extra options.
|
||||
- Create `web/service/use-{domain}.ts` only for orchestration or shared domain behavior.
|
||||
- Create or keep feature hooks only for real orchestration or shared domain behavior.
|
||||
- When touching thin `web/service/use-*` wrappers, migrate them away when feasible.
|
||||
3. Preserve Dify conventions.
|
||||
- Keep contract inputs in `{ params, query?, body? }` shape.
|
||||
- Bind invalidation in the service-layer mutation definition.
|
||||
- Bind default cache updates/invalidation in `createTanstackQueryUtils(...experimental_defaults...)`; use feature hooks only for workflows that cannot be expressed as default operation behavior.
|
||||
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required.
|
||||
|
||||
## Files Commonly Touched
|
||||
@ -33,7 +35,7 @@ description: Guide for implementing Dify frontend query and mutation patterns wi
|
||||
- `web/contract/marketplace.ts`
|
||||
- `web/contract/router.ts`
|
||||
- `web/service/client.ts`
|
||||
- `web/service/use-*.ts`
|
||||
- legacy `web/service/use-*.ts` files when migrating wrappers away
|
||||
- component and hook call sites using `consoleQuery` or `marketplaceQuery`
|
||||
|
||||
## References
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "Frontend Query & Mutation"
|
||||
short_description: "Dify TanStack Query and oRPC patterns"
|
||||
default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, conditional queries, invalidation, or legacy query/mutation migrations."
|
||||
short_description: "Dify TanStack Query, oRPC, and default option patterns"
|
||||
default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, oRPC default options, conditional queries, cache updates/invalidation, or legacy query/mutation migrations."
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
- Core workflow
|
||||
- Query usage decision rule
|
||||
- Mutation usage decision rule
|
||||
- Thin hook decision rule
|
||||
- Anti-patterns
|
||||
- Contract rules
|
||||
- Type export
|
||||
@ -55,9 +56,13 @@ const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({
|
||||
|
||||
1. Default to direct `*.queryOptions(...)` usage at the call site.
|
||||
2. If 3 or more call sites share the same extra options, extract a small query helper, not a `use-*` passthrough hook.
|
||||
3. Create `web/service/use-{domain}.ts` only for orchestration.
|
||||
3. Create or keep feature hooks only for orchestration.
|
||||
- Combine multiple queries or mutations.
|
||||
- Share domain-level derived state or invalidation helpers.
|
||||
- Prefer `web/features/{domain}/hooks/*` for feature-owned workflows.
|
||||
4. Treat `web/service/use-{domain}.ts` as legacy.
|
||||
- Do not create new thin service wrappers for oRPC contracts.
|
||||
- When touching existing wrappers, inline direct `consoleQuery` or `marketplaceQuery` consumption when the wrapper is only a passthrough.
|
||||
|
||||
```typescript
|
||||
const invoicesBaseQueryOptions = () =>
|
||||
@ -74,11 +79,37 @@ const invoiceQuery = useQuery({
|
||||
1. Default to mutation helpers from `consoleQuery` or `marketplaceQuery`, for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))`.
|
||||
2. If the mutation flow is heavily custom, use oRPC clients as `mutationFn`, for example `consoleClient.xxx` or `marketplaceClient.xxx`, instead of handwritten non-oRPC mutation logic.
|
||||
|
||||
```typescript
|
||||
const createTagMutation = useMutation(consoleQuery.tags.create.mutationOptions())
|
||||
```
|
||||
|
||||
## Thin Hook Decision Rule
|
||||
|
||||
Remove thin hooks when they only rename a single oRPC query or mutation helper.
|
||||
Keep hooks when they orchestrate business behavior across multiple operations, own local workflow state, or normalize a feature-specific API.
|
||||
Prefer feature vertical hooks for kept orchestration. Do not move new contract-first wrappers into `web/service/use-*`.
|
||||
|
||||
Use:
|
||||
|
||||
```typescript
|
||||
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
|
||||
```
|
||||
|
||||
Keep:
|
||||
|
||||
```typescript
|
||||
const applyTagBindingsMutation = useApplyTagBindingsMutation()
|
||||
```
|
||||
|
||||
`useApplyTagBindingsMutation` is acceptable because it coordinates bind and unbind requests, computes deltas, and exposes a feature-level workflow rather than a single endpoint passthrough.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Do not wrap `useQuery` with `options?: Partial<UseQueryOptions>`.
|
||||
- Do not split local `queryKey` and `queryFn` when oRPC `queryOptions` already exists and fits the use case.
|
||||
- Do not create thin `use-*` passthrough hooks for a single endpoint.
|
||||
- Do not create business-layer helpers whose only purpose is to call `consoleQuery.xxx.mutationOptions()` or `queryOptions()`.
|
||||
- Do not introduce new `web/service/use-*` files for oRPC contract passthroughs.
|
||||
- These patterns can degrade inference, especially around `throwOnError` and `select`, and add unnecessary indirection.
|
||||
|
||||
## Contract Rules
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
## Table of Contents
|
||||
|
||||
- Conditional queries
|
||||
- oRPC default options
|
||||
- Cache invalidation
|
||||
- Key API guide
|
||||
- `mutate` vs `mutateAsync`
|
||||
@ -35,9 +36,50 @@ function useBadAccessMode(appId: string | undefined) {
|
||||
}
|
||||
```
|
||||
|
||||
## oRPC Default Options
|
||||
|
||||
Use `experimental_defaults` in `createTanstackQueryUtils` when a contract operation should always carry shared TanStack Query behavior, such as default stale time, mutation cache writes, or invalidation.
|
||||
|
||||
Place defaults at the query utility creation point in `web/service/client.ts`:
|
||||
|
||||
```typescript
|
||||
export const consoleQuery = createTanstackQueryUtils(consoleClient, {
|
||||
path: ['console'],
|
||||
experimental_defaults: {
|
||||
tags: {
|
||||
create: {
|
||||
mutationOptions: {
|
||||
onSuccess: (tag, _variables, _result, context) => {
|
||||
context.client.setQueryData(
|
||||
consoleQuery.tags.list.queryKey({
|
||||
input: {
|
||||
query: {
|
||||
type: tag.type,
|
||||
},
|
||||
},
|
||||
}),
|
||||
(oldTags: Tag[] | undefined) => oldTags ? [tag, ...oldTags] : oldTags,
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep defaults inline in the `consoleQuery` or `marketplaceQuery` initialization when they need sibling oRPC key builders.
|
||||
- Do not create a wrapper function solely to host `createTanstackQueryUtils`.
|
||||
- Do not split defaults into a vertical feature file if that forces handwritten operation paths such as `generateOperationKey(['console', ...])`.
|
||||
- Keep feature-level orchestration in the feature vertical; keep query utility lifecycle defaults with the query utility.
|
||||
- Prefer call-site callbacks for UI feedback only; shared cache behavior belongs in oRPC defaults when it is tied to a contract operation.
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
Bind invalidation in the service-layer mutation definition.
|
||||
Bind shared invalidation in oRPC defaults when it is tied to a contract operation.
|
||||
Use feature vertical hooks only for multi-operation workflows or domain orchestration that cannot live in a single operation default.
|
||||
Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate.
|
||||
|
||||
Use:
|
||||
@ -49,7 +91,7 @@ Use:
|
||||
Do not use deprecated `useInvalid` from `use-base.ts`.
|
||||
|
||||
```typescript
|
||||
// Service layer owns cache invalidation.
|
||||
// Feature orchestration owns cache invalidation only when defaults are not enough.
|
||||
export const useUpdateAccessMode = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@ -124,7 +166,7 @@ When touching old code, migrate it toward these rules:
|
||||
|
||||
| Old pattern | New pattern |
|
||||
|---|---|
|
||||
| `useInvalid(key)` in service layer | `queryClient.invalidateQueries(...)` inside mutation `onSuccess` |
|
||||
| component-triggered invalidation after mutation | move invalidation into the service-layer mutation definition |
|
||||
| `useInvalid(key)` in service wrappers | oRPC defaults, or a feature vertical hook for real orchestration |
|
||||
| component-triggered invalidation after mutation | move invalidation into oRPC defaults or a feature vertical hook |
|
||||
| imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` |
|
||||
| `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` |
|
||||
|
||||
@ -23,7 +23,7 @@ from controllers.web.wraps import WebApiResource
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import uuid_value
|
||||
from models.model import App
|
||||
from models.model import App, EndUser
|
||||
from services.audio_service import AudioService
|
||||
from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
@ -69,12 +69,12 @@ class AudioApi(WebApiResource):
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model: App, end_user):
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
"""Convert audio to text"""
|
||||
file = request.files["file"]
|
||||
|
||||
try:
|
||||
response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=end_user)
|
||||
response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=end_user.external_user_id)
|
||||
|
||||
return response
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
@ -117,7 +117,7 @@ class TextApi(WebApiResource):
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
)
|
||||
def post(self, app_model: App, end_user):
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
"""Convert text to audio"""
|
||||
try:
|
||||
payload = TextToAudioPayload.model_validate(web_ns.payload or {})
|
||||
|
||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@ -71,6 +71,7 @@ def _pending_yaml_content(version: str = "99.0.0") -> bytes:
|
||||
|
||||
|
||||
def _app_stub(**overrides: Any) -> App:
|
||||
"""Create a stub App object for testing without hitting the database."""
|
||||
defaults = {
|
||||
"id": str(uuid4()),
|
||||
"tenant_id": _DEFAULT_TENANT_ID,
|
||||
@ -83,7 +84,10 @@ def _app_stub(**overrides: Any) -> App:
|
||||
"use_icon_as_answer_icon": False,
|
||||
"app_model_config": None,
|
||||
}
|
||||
return cast(App, SimpleNamespace(**(defaults | overrides)))
|
||||
app = MagicMock(spec=App)
|
||||
for key, value in (defaults | overrides).items():
|
||||
object.__setattr__(app, key, value)
|
||||
return app
|
||||
|
||||
|
||||
class TestAppDslService:
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "dify",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.0.6",
|
||||
"packageManager": "pnpm@11.0.8",
|
||||
"engines": {
|
||||
"node": "^22.22.1"
|
||||
},
|
||||
|
||||
2212
pnpm-lock.yaml
generated
2212
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -55,7 +55,7 @@ overrides:
|
||||
yauzl@<3.2.1: 3.2.1
|
||||
catalog:
|
||||
'@amplitude/analytics-browser': 2.42.1
|
||||
'@amplitude/plugin-session-replay-browser': 1.29.0
|
||||
'@amplitude/plugin-session-replay-browser': 1.30.1
|
||||
'@antfu/eslint-config': 8.2.0
|
||||
'@base-ui/react': 1.4.1
|
||||
'@chromatic-com/storybook': 5.1.2
|
||||
@ -83,16 +83,16 @@ catalog:
|
||||
'@mdx-js/react': 3.1.1
|
||||
'@mdx-js/rollup': 3.1.1
|
||||
'@monaco-editor/react': 4.7.0
|
||||
'@next/eslint-plugin-next': 16.2.4
|
||||
'@next/mdx': 16.2.4
|
||||
'@orpc/client': 1.14.1
|
||||
'@orpc/contract': 1.14.1
|
||||
'@orpc/openapi-client': 1.14.1
|
||||
'@orpc/tanstack-query': 1.14.1
|
||||
'@next/eslint-plugin-next': 16.2.6
|
||||
'@next/mdx': 16.2.6
|
||||
'@orpc/client': 1.14.2
|
||||
'@orpc/contract': 1.14.2
|
||||
'@orpc/openapi-client': 1.14.2
|
||||
'@orpc/tanstack-query': 1.14.2
|
||||
'@playwright/test': 1.59.1
|
||||
'@remixicon/react': 4.9.0
|
||||
'@rgrove/parse-xml': 4.2.0
|
||||
'@sentry/react': 10.51.0
|
||||
'@sentry/react': 10.52.0
|
||||
'@storybook/addon-docs': 10.3.6
|
||||
'@storybook/addon-links': 10.3.6
|
||||
'@storybook/addon-onboarding': 10.3.6
|
||||
@ -124,21 +124,21 @@ catalog:
|
||||
'@types/js-cookie': 3.0.6
|
||||
'@types/js-yaml': 4.0.9
|
||||
'@types/negotiator': 0.6.4
|
||||
'@types/node': 25.6.0
|
||||
'@types/qs': 6.15.0
|
||||
'@types/node': 25.6.2
|
||||
'@types/qs': 6.15.1
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3
|
||||
'@types/sortablejs': 1.15.9
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2
|
||||
'@typescript-eslint/parser': 8.59.2
|
||||
'@typescript/native-preview': 7.0.0-dev.20260505.1
|
||||
'@typescript/native-preview': 7.0.0-dev.20260507.1
|
||||
'@vitejs/plugin-react': 6.0.1
|
||||
'@vitejs/plugin-rsc': 0.5.25
|
||||
'@vitejs/plugin-rsc': 0.5.26
|
||||
'@vitest/coverage-v8': 4.1.5
|
||||
abcjs: 6.6.3
|
||||
agentation: 3.0.2
|
||||
ahooks: 3.9.7
|
||||
c12: 1.10.0
|
||||
c12: 1.11.2
|
||||
class-variance-authority: 0.7.1
|
||||
client-only: 0.0.1
|
||||
clsx: 2.1.1
|
||||
@ -158,7 +158,7 @@ catalog:
|
||||
emoji-mart: 5.6.0
|
||||
es-toolkit: 1.46.1
|
||||
eslint: 10.3.0
|
||||
eslint-markdown: 0.8.0
|
||||
eslint-markdown: 0.9.0
|
||||
eslint-plugin-better-tailwindcss: 4.5.0
|
||||
eslint-plugin-hyoban: 0.14.1
|
||||
eslint-plugin-markdown-preferences: 0.41.1
|
||||
@ -167,23 +167,23 @@ catalog:
|
||||
eslint-plugin-sonarjs: 4.0.3
|
||||
eslint-plugin-storybook: 10.3.6
|
||||
fast-deep-equal: 3.1.3
|
||||
fuse.js: 7.2.0
|
||||
fuse.js: 7.3.0
|
||||
happy-dom: 20.9.0
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
hono: 4.12.17
|
||||
hono: 4.12.18
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
i18next: 26.0.8
|
||||
i18next: 26.0.10
|
||||
i18next-resources-to-backend: 1.2.1
|
||||
iconify-import-svg: 0.2.0
|
||||
immer: 11.1.6
|
||||
immer: 11.1.7
|
||||
jotai: 2.20.0
|
||||
js-audio-recorder: 1.0.7
|
||||
js-cookie: 3.0.5
|
||||
js-yaml: 4.1.1
|
||||
jsonschema: 1.5.0
|
||||
katex: 0.16.45
|
||||
knip: 6.11.0
|
||||
knip: 6.12.1
|
||||
ky: 2.0.2
|
||||
lamejs: 1.2.1
|
||||
lexical: 0.44.0
|
||||
@ -192,7 +192,7 @@ catalog:
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
negotiator: 1.0.0
|
||||
next: 16.2.4
|
||||
next: 16.2.6
|
||||
next-themes: 0.4.6
|
||||
nuqs: 2.8.9
|
||||
pinyin-pro: 3.28.1
|
||||
@ -200,9 +200,9 @@ catalog:
|
||||
postcss: 8.5.14
|
||||
qrcode.react: 4.2.0
|
||||
qs: 6.15.1
|
||||
react: 19.2.5
|
||||
react: 19.2.6
|
||||
react-18-input-autosize: 3.0.0
|
||||
react-dom: 19.2.5
|
||||
react-dom: 19.2.6
|
||||
react-easy-crop: 5.5.7
|
||||
react-hotkeys-hook: 5.3.2
|
||||
react-i18next: 16.5.8
|
||||
@ -233,7 +233,7 @@ catalog:
|
||||
unist-util-visit: 5.1.0
|
||||
use-context-selector: 2.0.0
|
||||
uuid: 14.0.0
|
||||
vinext: 0.0.47
|
||||
vinext: 0.0.49
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.1.20
|
||||
vite-plugin-inspect: 12.0.0-beta.1
|
||||
vite-plus: 0.1.20
|
||||
|
||||
@ -136,6 +136,10 @@ export default class AudioPlayer {
|
||||
}
|
||||
else {
|
||||
this.isLoadData = true
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
})
|
||||
this.loadAudio()
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,24 +23,34 @@ const tagMocks = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks/use-tag-mutations', () => ({
|
||||
useUpdateTagMutation: () => ({
|
||||
mutate: ({ params, body }: { params: { tagId: string }, body: { name: string } }, options?: { onSuccess?: () => void, onError?: () => void }) => {
|
||||
Promise.resolve(tagMocks.updateTag(params.tagId, body.name))
|
||||
.then(() => options?.onSuccess?.())
|
||||
.catch(() => options?.onError?.())
|
||||
},
|
||||
}),
|
||||
useDeleteTagMutation: () => ({
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: ({ params }: { params: { tagId: string } }, options?: { onSuccess?: () => void, onError?: () => void }) => {
|
||||
Promise.resolve(tagMocks.deleteTag(params.tagId))
|
||||
mutate: (input: unknown, options?: { onSuccess?: () => void, onError?: () => void }) => {
|
||||
Promise.resolve(mutationOptions.mutationFn(input))
|
||||
.then(() => options?.onSuccess?.())
|
||||
.catch(() => options?.onError?.())
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
tags: {
|
||||
update: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: ({ params, body }: { params: { tagId: string }, body: { name: string } }) => tagMocks.updateTag(params.tagId, body.name),
|
||||
}),
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: ({ params }: { params: { tagId: string } }) => tagMocks.deleteTag(params.tagId),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: tagMocks.api,
|
||||
}))
|
||||
|
||||
@ -30,29 +30,39 @@ const { mockUseQueryData, createTag } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => ({ data: mockUseQueryData.current }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-tag-mutations', () => ({
|
||||
useCreateTagMutation: () => ({
|
||||
useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }, options?: { onSuccess?: (tag: Tag) => void, onError?: () => void }) => {
|
||||
const tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 } as Tag
|
||||
Promise.resolve(createTag(body.name, body.type))
|
||||
.then(() => options?.onSuccess?.(tag))
|
||||
mutate: (input: unknown, options?: { onSuccess?: () => void, onError?: () => void }) => {
|
||||
Promise.resolve(mutationOptions.mutationFn(input))
|
||||
.then(() => options?.onSuccess?.())
|
||||
.catch(() => options?.onError?.())
|
||||
},
|
||||
}),
|
||||
useUpdateTagMutation: () => ({
|
||||
mutate: (_input: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
tags: {
|
||||
list: {
|
||||
queryOptions: () => ({}),
|
||||
},
|
||||
create: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }) => createTag(body.name, body.type),
|
||||
}),
|
||||
},
|
||||
update: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: () => Promise.resolve(undefined),
|
||||
}),
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: () => Promise.resolve(undefined),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
useDeleteTagMutation: () => ({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockTags: Tag[] = [
|
||||
|
||||
@ -31,18 +31,32 @@ const { mockUseQueryData, createTag, bindTag, unBindTag } = vi.hoisted(() => {
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => ({ data: mockUseQueryData.current }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-tag-mutations', () => ({
|
||||
useCreateTagMutation: () => ({
|
||||
useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise<unknown> }) => ({
|
||||
isPending: false,
|
||||
mutate: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }, options?: { onSuccess?: (tag: Tag) => void, onError?: () => void }) => {
|
||||
const tag: Tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 }
|
||||
Promise.resolve(createTag(body.name, body.type))
|
||||
.then(() => options?.onSuccess?.(tag))
|
||||
mutate: (input: unknown, options?: { onSuccess?: () => void, onError?: () => void }) => {
|
||||
Promise.resolve(mutationOptions.mutationFn(input))
|
||||
.then(() => options?.onSuccess?.())
|
||||
.catch(() => options?.onError?.())
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
tags: {
|
||||
list: {
|
||||
queryOptions: () => ({}),
|
||||
},
|
||||
create: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }) => createTag(body.name, body.type),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-tag-mutations', () => ({
|
||||
useApplyTagBindingsMutation: () => ({
|
||||
mutate: (
|
||||
{ currentTagIds, nextTagIds, targetId, type }: { currentTagIds: string[], nextTagIds: string[], targetId: string, type: 'app' | 'knowledge' },
|
||||
|
||||
@ -15,10 +15,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDeleteTagMutation, useUpdateTagMutation } from '../hooks/use-tag-mutations'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type TagItemEditorProps = {
|
||||
tag: Tag
|
||||
@ -26,8 +27,8 @@ type TagItemEditorProps = {
|
||||
}
|
||||
export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const updateTagMutation = useUpdateTagMutation(tag.type)
|
||||
const deleteTagMutation = useDeleteTagMutation(tag.type)
|
||||
const updateTagMutation = useMutation(consoleQuery.tags.update.mutationOptions())
|
||||
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [name, setName] = useState(tag.name)
|
||||
const editTag = (tagId: string, name: string) => {
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useCreateTagMutation } from '../hooks/use-tag-mutations'
|
||||
import { TagItemEditor } from './tag-item-editor'
|
||||
|
||||
type TagManagementModalProps = {
|
||||
@ -24,7 +23,7 @@ export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagMan
|
||||
},
|
||||
enabled: show,
|
||||
}))
|
||||
const createTagMutation = useCreateTagMutation()
|
||||
const createTagMutation = useMutation(consoleQuery.tags.create.mutationOptions())
|
||||
const [name, setName] = useState<string>('')
|
||||
|
||||
const createNewTag = () => {
|
||||
@ -55,8 +54,8 @@ export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagMan
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={open => !open && handleClose()}>
|
||||
<DialogContent className="w-[600px]! max-w-[600px]! rounded-xl! p-8!">
|
||||
<div className="relative pb-2 text-xl leading-[30px] font-semibold text-text-primary">{t('tag.manageTags', { ns: 'common' })}</div>
|
||||
<DialogContent className="w-150 max-w-150 rounded-xl p-8">
|
||||
<div className="relative pb-2 text-xl leading-7.5 font-semibold text-text-primary">{t('tag.manageTags', { ns: 'common' })}</div>
|
||||
<DialogCloseButton data-testid="tag-management-modal-close-button" className="top-4 right-4" />
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<input className="w-25 shrink-0 appearance-none rounded-lg border border-dashed border-divider-regular bg-transparent px-2 py-1 text-sm leading-5 text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary focus:border-solid" placeholder={t('tag.addNew', { ns: 'common' }) || ''} autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} />
|
||||
|
||||
@ -5,11 +5,11 @@ import type { Tag, TagType } from '@/contract/console/tags'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useApplyTagBindingsMutation, useCreateTagMutation } from '../hooks/use-tag-mutations'
|
||||
import { useApplyTagBindingsMutation } from '../hooks/use-tag-mutations'
|
||||
import { isCreateTagOption } from './tag-combobox-item'
|
||||
import { TagPanel } from './tag-panel'
|
||||
import { TagTrigger } from './tag-trigger'
|
||||
@ -67,7 +67,10 @@ export const TagSelector = ({
|
||||
const [draftTags, setDraftTags] = useState<Tag[]>(value)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const applyTagBindingsMutation = useApplyTagBindingsMutation()
|
||||
const createTagMutation = useCreateTagMutation()
|
||||
const {
|
||||
isPending: isCreatingTag,
|
||||
mutate: createTag,
|
||||
} = useMutation(consoleQuery.tags.create.mutationOptions())
|
||||
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
@ -164,10 +167,10 @@ export const TagSelector = ({
|
||||
}, [applyTagBindings, value])
|
||||
|
||||
const createNewTag = useCallback((name: string) => {
|
||||
if (!name || createTagMutation.isPending)
|
||||
if (!name || isCreatingTag)
|
||||
return
|
||||
|
||||
createTagMutation.mutate({
|
||||
createTag({
|
||||
body: {
|
||||
name,
|
||||
type,
|
||||
@ -181,7 +184,7 @@ export const TagSelector = ({
|
||||
toast.error(t('tag.failed', { ns: 'common' }))
|
||||
},
|
||||
})
|
||||
}, [createTagMutation, t, type])
|
||||
}, [createTag, isCreatingTag, t, type])
|
||||
|
||||
const handleValueChange = useCallback((nextTags: TagComboboxItem[]) => {
|
||||
const createOption = nextTags.find(isCreateTagOption)
|
||||
|
||||
@ -1,31 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
useApplyTagBindingsMutation,
|
||||
useCreateTagMutation,
|
||||
useDeleteTagMutation,
|
||||
useUpdateTagMutation,
|
||||
} from '../use-tag-mutations'
|
||||
import { useApplyTagBindingsMutation } from '../use-tag-mutations'
|
||||
|
||||
const {
|
||||
bindTag,
|
||||
createTagMutationOptions,
|
||||
deleteTagMutationOptions,
|
||||
listQueryOptions,
|
||||
listKey,
|
||||
unbindTag,
|
||||
updateTagMutationOptions,
|
||||
} = vi.hoisted(() => ({
|
||||
bindTag: vi.fn(),
|
||||
createTagMutationOptions: vi.fn(),
|
||||
deleteTagMutationOptions: vi.fn(),
|
||||
listQueryOptions: vi.fn((options: { input: { query: { type: string } } }) => ({
|
||||
queryKey: ['console', 'tags', 'list', options.input.query.type],
|
||||
})),
|
||||
listKey: vi.fn((options: { type: 'query', input: { query: { type: string } } }) => ['console', 'tags', 'list', 'query', options.input.query.type]),
|
||||
unbindTag: vi.fn(),
|
||||
updateTagMutationOptions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
@ -37,30 +23,13 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
consoleQuery: {
|
||||
tags: {
|
||||
create: {
|
||||
mutationOptions: createTagMutationOptions,
|
||||
},
|
||||
update: {
|
||||
mutationOptions: updateTagMutationOptions,
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: deleteTagMutationOptions,
|
||||
},
|
||||
list: {
|
||||
queryOptions: listQueryOptions,
|
||||
key: listKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const appTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||
id: 'tag-1',
|
||||
name: 'Frontend',
|
||||
type: 'app',
|
||||
binding_count: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -91,127 +60,6 @@ describe('useTagMutations', () => {
|
||||
vi.clearAllMocks()
|
||||
bindTag.mockResolvedValue(undefined)
|
||||
unbindTag.mockResolvedValue(undefined)
|
||||
createTagMutationOptions.mockImplementation((options: Record<string, unknown>) => ({
|
||||
mutationFn: ({ body }: { body: { name: string, type: Tag['type'] } }) => Promise.resolve(appTag({
|
||||
id: 'created-tag',
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
binding_count: 0,
|
||||
})),
|
||||
...options,
|
||||
}))
|
||||
updateTagMutationOptions.mockImplementation((options: Record<string, unknown>) => ({
|
||||
mutationFn: () => Promise.resolve({ result: 'success' }),
|
||||
...options,
|
||||
}))
|
||||
deleteTagMutationOptions.mockImplementation((options: Record<string, unknown>) => ({
|
||||
mutationFn: () => Promise.resolve({ result: 'success' }),
|
||||
...options,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Create Tag', () => {
|
||||
it('should prepend the created tag to the matching tag list cache', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useCreateTagMutation())
|
||||
const cacheKey = ['console', 'tags', 'list', 'app']
|
||||
queryClient.setQueryData<Tag[]>(cacheKey, [
|
||||
appTag({ id: 'existing-tag', name: 'Existing' }),
|
||||
])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
body: {
|
||||
name: 'Created',
|
||||
type: 'app',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<Tag[]>(cacheKey)).toEqual([
|
||||
appTag({ id: 'created-tag', name: 'Created', binding_count: 0 }),
|
||||
appTag({ id: 'existing-tag', name: 'Existing' }),
|
||||
])
|
||||
expect(listQueryOptions).toHaveBeenCalledWith({
|
||||
input: {
|
||||
query: {
|
||||
type: 'app',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave an absent tag list cache absent after creating a tag', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useCreateTagMutation())
|
||||
const cacheKey = ['console', 'tags', 'list', 'knowledge']
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
body: {
|
||||
name: 'Knowledge',
|
||||
type: 'knowledge',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<Tag[]>(cacheKey)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update Tag', () => {
|
||||
it('should rename only the matching tag in the matching tag list cache', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useUpdateTagMutation('app'))
|
||||
const appCacheKey = ['console', 'tags', 'list', 'app']
|
||||
const knowledgeCacheKey = ['console', 'tags', 'list', 'knowledge']
|
||||
queryClient.setQueryData<Tag[]>(appCacheKey, [
|
||||
appTag({ id: 'tag-1', name: 'Old name' }),
|
||||
appTag({ id: 'tag-2', name: 'Unchanged' }),
|
||||
])
|
||||
queryClient.setQueryData<Tag[]>(knowledgeCacheKey, [
|
||||
appTag({ id: 'tag-1', name: 'Old knowledge name', type: 'knowledge' }),
|
||||
])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
params: {
|
||||
tagId: 'tag-1',
|
||||
},
|
||||
body: {
|
||||
name: 'Renamed',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<Tag[]>(appCacheKey)).toEqual([
|
||||
appTag({ id: 'tag-1', name: 'Renamed' }),
|
||||
appTag({ id: 'tag-2', name: 'Unchanged' }),
|
||||
])
|
||||
expect(queryClient.getQueryData<Tag[]>(knowledgeCacheKey)).toEqual([
|
||||
appTag({ id: 'tag-1', name: 'Old knowledge name', type: 'knowledge' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Tag', () => {
|
||||
it('should remove the deleted tag from the matching tag list cache', async () => {
|
||||
const { queryClient, result } = renderMutationHook(() => useDeleteTagMutation('app'))
|
||||
const cacheKey = ['console', 'tags', 'list', 'app']
|
||||
queryClient.setQueryData<Tag[]>(cacheKey, [
|
||||
appTag({ id: 'tag-1' }),
|
||||
appTag({ id: 'tag-2', name: 'Backend' }),
|
||||
])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
params: {
|
||||
tagId: 'tag-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<Tag[]>(cacheKey)).toEqual([
|
||||
appTag({ id: 'tag-2', name: 'Backend' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apply Tag Bindings', () => {
|
||||
@ -244,9 +92,17 @@ describe('useTagMutations', () => {
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['console', 'tags', 'list', 'app'],
|
||||
queryKey: ['console', 'tags', 'list', 'query', 'app'],
|
||||
})
|
||||
})
|
||||
expect(listKey).toHaveBeenCalledWith({
|
||||
type: 'query',
|
||||
input: {
|
||||
query: {
|
||||
type: 'app',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip network requests when tag bindings do not change but still invalidate tags', async () => {
|
||||
@ -266,9 +122,17 @@ describe('useTagMutations', () => {
|
||||
expect(unbindTag).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['console', 'tags', 'list', 'knowledge'],
|
||||
queryKey: ['console', 'tags', 'list', 'query', 'knowledge'],
|
||||
})
|
||||
})
|
||||
expect(listKey).toHaveBeenCalledWith({
|
||||
type: 'query',
|
||||
input: {
|
||||
query: {
|
||||
type: 'knowledge',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,58 +2,6 @@ import type { TagType } from '@/contract/console/tags'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
|
||||
const getTagsListQueryOptions = (tagType: TagType) => consoleQuery.tags.list.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
type: tagType,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const useCreateTagMutation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation(consoleQuery.tags.create.mutationOptions({
|
||||
onSuccess: (tag) => {
|
||||
queryClient.setQueryData(
|
||||
getTagsListQueryOptions(tag.type).queryKey,
|
||||
oldTags => oldTags ? [tag, ...oldTags] : oldTags,
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export const useUpdateTagMutation = (tagType: TagType) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation(consoleQuery.tags.update.mutationOptions({
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.setQueryData(
|
||||
getTagsListQueryOptions(tagType).queryKey,
|
||||
oldTags => oldTags?.map(tag => tag.id === variables.params.tagId
|
||||
? {
|
||||
...tag,
|
||||
name: variables.body.name,
|
||||
}
|
||||
: tag),
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export const useDeleteTagMutation = (tagType: TagType) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation(consoleQuery.tags.delete.mutationOptions({
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.setQueryData(
|
||||
getTagsListQueryOptions(tagType).queryKey,
|
||||
oldTags => oldTags?.filter(tag => tag.id !== variables.params.tagId),
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
type ApplyTagBindingsInput = {
|
||||
currentTagIds: string[]
|
||||
nextTagIds: string[]
|
||||
@ -95,7 +43,14 @@ export const useApplyTagBindingsMutation = () => {
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: getTagsListQueryOptions(variables.type).queryKey,
|
||||
queryKey: consoleQuery.tags.list.key({
|
||||
type: 'query',
|
||||
input: {
|
||||
query: {
|
||||
type: variables.type,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@ -261,14 +261,14 @@
|
||||
"notSetAPIKey.trailFinished": "Deneme süresi sona erdi",
|
||||
"notSetVar": "Değişkenler, kullanıcıların form doldururken prompt kelimelerini veya açılış ifadelerini getirmesine izin verir. Prompt kelimelerine \"{{input}}\" yazmayı deneyebilirsiniz.",
|
||||
"openingStatement.add": "Ekle",
|
||||
"openingStatement.editorTitle": "Acilis Mesaji",
|
||||
"openingStatement.editorTitle": "Açılış Mesajı",
|
||||
"openingStatement.noDataPlaceHolder": "Kullanıcı ile konuşmayı başlatmak, AI'ın konuşma uygulamalarında onlarla daha yakın bir bağlantı kurmasına yardımcı olabilir.",
|
||||
"openingStatement.notIncludeKey": "Başlangıç promptu değişkeni içermiyor: {{key}}. Lütfen bunu başlangıç promptuna ekleyin.",
|
||||
"openingStatement.openingQuestion": "Açılış Soruları",
|
||||
"openingStatement.openingQuestionDescription": "Acilis mesajindan sonra gosterilen istege bagli yonlendirmeler; kullanicilarin konusmayi surdurmesine yardimci olur.",
|
||||
"openingStatement.openingQuestionPlaceholder": "Bir acilis sorusu girin.",
|
||||
"openingStatement.placeholderLine1": "Buradan baslayin. Yapay zekanin gondermesi gereken ilk mesaji yazin.",
|
||||
"openingStatement.placeholderLine2": "Değişkenler kullanabilirsiniz, {{variable}} yazmayı deneyin.",
|
||||
"openingStatement.openingQuestionDescription": "Açılış mesajından sonra gösterilen isteğe bağlı yönlendirmeler; kullanıcıların konuşmayı sürdürmesine yardımcı olur.",
|
||||
"openingStatement.openingQuestionPlaceholder": "Bir açılış sorusu girin.",
|
||||
"openingStatement.placeholderLine1": "Buradan başlayın. Yapay zekânın göndermesi gereken ilk mesajı yazın.",
|
||||
"openingStatement.placeholderLine2": "Değişkenleri kullanabilirsiniz; {{variable}} yazmayı deneyin.",
|
||||
"openingStatement.title": "Konuşma Başlatıcı",
|
||||
"openingStatement.tooShort": "Konuşma için açılış ifadeleri oluşturmak için en az 20 kelimelik başlangıç promptu gereklidir.",
|
||||
"openingStatement.varTip": "Değişkenler kullanabilirsiniz, örneğin {{variable}} yazmayı deneyin",
|
||||
|
||||
@ -136,11 +136,11 @@
|
||||
"marketplace.template.fetchFailed": "Şablon alınamadı",
|
||||
"marketplace.template.importConfirm": "İçe Aktar",
|
||||
"marketplace.template.importFailed": "Şablon içe aktarılamadı",
|
||||
"marketplace.template.modalTitle": "Marketplace'den İçe Aktar",
|
||||
"marketplace.template.modalTitle": "Pazar Yeri'nden İçe Aktar",
|
||||
"marketplace.template.overview": "Genel Bakış",
|
||||
"marketplace.template.publishedBy": "Yayıncı",
|
||||
"marketplace.template.usageCount": "Kullanım",
|
||||
"marketplace.template.viewOnMarketplace": "Marketplace'de Görüntüle",
|
||||
"marketplace.template.viewOnMarketplace": "Pazar Yeri'nde Görüntüle",
|
||||
"maxActiveRequests": "Maksimum eş zamanlı istekler",
|
||||
"maxActiveRequestsPlaceholder": "Sınırsız için 0 girin",
|
||||
"maxActiveRequestsTip": "Her uygulama için maksimum eşzamanlı aktif istek sayısı (sınırsız için 0)",
|
||||
|
||||
@ -1,24 +1,24 @@
|
||||
{
|
||||
"applied.activeSubscription.description": "Aktif bir aboneliğiniz var. Aboneliğinizin süresi dolduktan sonra eğitim indirimini kullanabilirsiniz. Aboneliğinizi <stripeLink>Stripe</stripeLink>'da onaylayın.",
|
||||
"applied.description": "Tebrikler! Eğitim indirimi için başarıyla başvurdunuz.",
|
||||
"applied.noPaymentPermission.description": "Bu workspace'te ödeme izniniz yok. Eğitim indirimini kullanmak için lütfen faturalamayı yönetebileceğiniz bir workspace'e geçin.",
|
||||
"applied.noPaymentPermission.description": "Bu çalışma alanında ödeme izniniz yok. Eğitim indirimini kullanmak için lütfen faturalamayı yönetebileceğiniz bir çalışma alanına geçin.",
|
||||
"applied.noPaymentPermission.returnHome": "Dify'e geri dön",
|
||||
"applied.step1.description": "Eğitim indirimi için başarıyla başvurdunuz.",
|
||||
"applied.step1.title": "Adım 1",
|
||||
"applied.step2.description": "Eğitim indirimiyle kullanmak istediğiniz workspace'i seçin.",
|
||||
"applied.step2.description": "Eğitim indirimiyle kullanmak istediğiniz çalışma alanını seçin.",
|
||||
"applied.step2.title": "Adım 2",
|
||||
"applied.tabs.activeSubscription": "Abonelikte",
|
||||
"applied.tabs.eligible": "Satın alabilir",
|
||||
"applied.tabs.noPaymentPermission": "Ödeme izni yok",
|
||||
"applied.title": "Eğitim indirimi uygulandı",
|
||||
"applied.workspace.plan": "Ücretli plan",
|
||||
"applied.workspace.title": "Mevcut Workspace",
|
||||
"applied.workspace.title": "Mevcut Çalışma Alanı",
|
||||
"currentSigned": "ŞU ANDA GİRİŞ YAPILDIĞI KİŞİ",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "aylık",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "yıllık",
|
||||
"educationPricingConfirm.cancel": "İptal",
|
||||
"educationPricingConfirm.continue": "İndirim olmadan devam et",
|
||||
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} planınız eğitim indirimini desteklemiyor. Yalnızca Professional yıllık plan uygun.",
|
||||
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} planınız eğitim indirimini desteklemiyor. Yalnızca yıllık Professional planı uygundur.",
|
||||
"educationPricingConfirm.title": "Eğitim indirimi mevcut değil",
|
||||
"emailLabel": "Şu anki e-posta adresin",
|
||||
"form.schoolName.placeholder": "Okulunuzun resmi, kısaltılmamış adını girin",
|
||||
|
||||
@ -16,8 +16,8 @@
|
||||
"chat.privacyPolicyMiddle": "gizlilik politikası",
|
||||
"chat.privacyPolicyRight": "uygulama geliştiricisi tarafından sağlanmıştır.",
|
||||
"chat.privatePromptConfigTitle": "Konuşma ayarları",
|
||||
"chat.prompt": "Prompt",
|
||||
"chat.publicPromptConfigTitle": "Başlangıç Promptu",
|
||||
"chat.prompt": "İstem",
|
||||
"chat.publicPromptConfigTitle": "Başlangıç İstemi",
|
||||
"chat.resetChat": "Konuşmayı sıfırla",
|
||||
"chat.startChat": "Sohbete Başla",
|
||||
"chat.temporarySystemIssue": "Üzgünüz, geçici sistem sorunu.",
|
||||
|
||||
@ -111,33 +111,33 @@
|
||||
"chatVariable.updatedAt": "Güncellenme zamanı: ",
|
||||
"collaboration.historyAction.generic": "Bir işbirlikçi geri alma/yeniden yapma gerçekleştirdi",
|
||||
"comments.actions.addComment": "Yorum ekle",
|
||||
"comments.actions.deleteReply": "Delete reply",
|
||||
"comments.actions.deleteReply": "Yanıtı sil",
|
||||
"comments.actions.editComment": "Yorumu düzenle",
|
||||
"comments.actions.editReply": "Edit reply",
|
||||
"comments.aria.closeComment": "Close comment",
|
||||
"comments.actions.editReply": "Yanıtı düzenle",
|
||||
"comments.aria.closeComment": "Yorumu kapat",
|
||||
"comments.aria.commentActions": "Yorum işlemleri",
|
||||
"comments.aria.deleteComment": "Delete thread",
|
||||
"comments.aria.deleteComment": "Konuyu sil",
|
||||
"comments.aria.filterComments": "Yorumları filtrele",
|
||||
"comments.aria.nextComment": "Next comment",
|
||||
"comments.aria.previousComment": "Previous comment",
|
||||
"comments.aria.replyActions": "Reply actions",
|
||||
"comments.aria.resolveComment": "Resolve",
|
||||
"comments.confirm.deleteReplyDesc": "This reply will be removed permanently.",
|
||||
"comments.confirm.deleteReplyTitle": "Delete this reply?",
|
||||
"comments.confirm.deleteThreadDesc": "This action will permanently delete the thread and all its replies. This cannot be undone.",
|
||||
"comments.confirm.deleteThreadTitle": "Delete this thread?",
|
||||
"comments.fallback.user": "User",
|
||||
"comments.aria.nextComment": "Sonraki yorum",
|
||||
"comments.aria.previousComment": "Önceki yorum",
|
||||
"comments.aria.replyActions": "Yanıt işlemleri",
|
||||
"comments.aria.resolveComment": "Çöz",
|
||||
"comments.confirm.deleteReplyDesc": "Bu yanıt kalıcı olarak kaldırılacak.",
|
||||
"comments.confirm.deleteReplyTitle": "Bu yanıt silinsin mi?",
|
||||
"comments.confirm.deleteThreadDesc": "Bu işlem konuyu ve tüm yanıtlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
|
||||
"comments.confirm.deleteThreadTitle": "Bu konu silinsin mi?",
|
||||
"comments.fallback.user": "Kullanıcı",
|
||||
"comments.filter.all": "Tümü",
|
||||
"comments.filter.onlyYourThreads": "Yalnızca senin başlıkların",
|
||||
"comments.filter.onlyYourThreads": "Yalnızca kendi konuların",
|
||||
"comments.filter.showResolved": "Çözülenleri göster",
|
||||
"comments.loading": "Loading…",
|
||||
"comments.noComments": "No comments yet",
|
||||
"comments.panelTitle": "Comment",
|
||||
"comments.placeholder.add": "Add a comment",
|
||||
"comments.loading": "Yükleniyor…",
|
||||
"comments.noComments": "Henüz yorum yok",
|
||||
"comments.panelTitle": "Yorum",
|
||||
"comments.placeholder.add": "Yorum ekle",
|
||||
"comments.placeholder.editComment": "Yorumu düzenle",
|
||||
"comments.placeholder.editReply": "Edit reply",
|
||||
"comments.placeholder.reply": "Reply",
|
||||
"comments.reply": "Reply",
|
||||
"comments.placeholder.editReply": "Yanıtı düzenle",
|
||||
"comments.placeholder.reply": "Yanıtla",
|
||||
"comments.reply": "Yanıtla",
|
||||
"common.ImageUploadLegacyTip": "Artık başlangıç formunda dosya türü değişkenleri oluşturabilirsiniz. Gelecekte resim yükleme özelliğini artık desteklemeyeceğiz.",
|
||||
"common.accessAPIReference": "API Referansına Eriş",
|
||||
"common.addBlock": "Düğüm Ekle",
|
||||
@ -229,8 +229,8 @@
|
||||
"common.previewPlaceholder": "Sohbet Robotunu hata ayıklamak için aşağıdaki kutuya içerik girin",
|
||||
"common.processData": "Veriyi İşle",
|
||||
"common.publish": "Yayınla",
|
||||
"common.publishToMarketplace": "Marketplace'de Yayınla",
|
||||
"common.publishToMarketplaceFailed": "Marketplace'de Yayınlama Başarısız",
|
||||
"common.publishToMarketplace": "Pazar Yeri'nde Yayınla",
|
||||
"common.publishToMarketplaceFailed": "Pazar Yeri'nde yayınlanamadı",
|
||||
"common.publishUpdate": "Güncellemeyi Yayınla",
|
||||
"common.published": "Yayınlandı",
|
||||
"common.publishedAt": "Yayınlandı",
|
||||
@ -812,7 +812,7 @@
|
||||
"nodes.llm.outputVars.output": "İçerik Üret",
|
||||
"nodes.llm.outputVars.reasoning_content": "Akıl yürütme içeriği",
|
||||
"nodes.llm.outputVars.usage": "Model Kullanım Bilgileri",
|
||||
"nodes.llm.prompt": "prompt",
|
||||
"nodes.llm.prompt": "istem",
|
||||
"nodes.llm.reasoningFormat.separated": "Ayrı düşünce etiketleri",
|
||||
"nodes.llm.reasoningFormat.tagged": "Etiketleri düşünmeye devam et",
|
||||
"nodes.llm.reasoningFormat.title": "Akıl yürütme etiket ayrımını etkinleştir",
|
||||
@ -891,7 +891,7 @@
|
||||
"nodes.parameterExtractor.outputVars.usage": "Model Kullanım Bilgileri",
|
||||
"nodes.parameterExtractor.reasoningMode": "Akıl Yürütme Modu",
|
||||
"nodes.parameterExtractor.reasoningModeFunctionToolCalling": "Fonksiyon/Araç Çağrısı",
|
||||
"nodes.parameterExtractor.reasoningModePrompt": "Prompt",
|
||||
"nodes.parameterExtractor.reasoningModePrompt": "İstem",
|
||||
"nodes.parameterExtractor.reasoningModeTip": "Modelin fonksiyon çağırma veya istemler için talimatlara yanıt verme yeteneğine bağlı olarak uygun akıl yürütme modunu seçebilirsiniz.",
|
||||
"nodes.questionClassifiers.addClass": "Sınıf Ekle",
|
||||
"nodes.questionClassifiers.advancedSetting": "Gelişmiş Ayarlar",
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import type { MutationFunctionContext } from '@tanstack/react-query'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const loadGetBaseURL = async (isClientValue: boolean) => {
|
||||
@ -10,6 +13,28 @@ const loadGetBaseURL = async (isClientValue: boolean) => {
|
||||
return { getBaseURL: module.getBaseURL, warnSpy }
|
||||
}
|
||||
|
||||
const loadConsoleQuery = async () => {
|
||||
vi.resetModules()
|
||||
vi.doMock('@/utils/client', () => ({ isClient: true, isServer: false }))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const module = await import('./client')
|
||||
warnSpy.mockRestore()
|
||||
return module.consoleQuery
|
||||
}
|
||||
|
||||
const createMutationContext = (queryClient: QueryClient): MutationFunctionContext => ({
|
||||
client: queryClient,
|
||||
meta: undefined,
|
||||
})
|
||||
|
||||
const createTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||
id: 'tag-1',
|
||||
name: 'Frontend',
|
||||
type: 'app',
|
||||
binding_count: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Scenario: base URL selection and warnings.
|
||||
describe('getBaseURL', () => {
|
||||
beforeEach(() => {
|
||||
@ -78,3 +103,156 @@ describe('getBaseURL', () => {
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: oRPC mutation defaults own shared tag cache behavior.
|
||||
describe('consoleQuery tag mutation defaults', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should add created tags to the matching list query cache', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const appListKey = consoleQuery.tags.list.queryKey({
|
||||
input: {
|
||||
query: {
|
||||
type: 'app',
|
||||
},
|
||||
},
|
||||
})
|
||||
const knowledgeListKey = consoleQuery.tags.list.queryKey({
|
||||
input: {
|
||||
query: {
|
||||
type: 'knowledge',
|
||||
},
|
||||
},
|
||||
})
|
||||
const existingAppTag = createTag({ id: 'tag-1', name: 'Existing' })
|
||||
const existingKnowledgeTag = createTag({
|
||||
id: 'knowledge-tag-1',
|
||||
name: 'Knowledge',
|
||||
type: 'knowledge',
|
||||
})
|
||||
const createdTag = createTag({ id: 'tag-2', name: 'Created' })
|
||||
|
||||
queryClient.setQueryData(appListKey, [existingAppTag])
|
||||
queryClient.setQueryData(knowledgeListKey, [existingKnowledgeTag])
|
||||
|
||||
const mutationOptions = consoleQuery.tags.create.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
createdTag,
|
||||
{
|
||||
body: {
|
||||
name: createdTag.name,
|
||||
type: createdTag.type,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(appListKey)).toEqual([createdTag, existingAppTag])
|
||||
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([existingKnowledgeTag])
|
||||
})
|
||||
|
||||
it('should update matching tags across cached list queries', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const appListKey = consoleQuery.tags.list.queryKey({
|
||||
input: {
|
||||
query: {
|
||||
type: 'app',
|
||||
},
|
||||
},
|
||||
})
|
||||
const knowledgeListKey = consoleQuery.tags.list.queryKey({
|
||||
input: {
|
||||
query: {
|
||||
type: 'knowledge',
|
||||
},
|
||||
},
|
||||
})
|
||||
const targetTag = createTag({ id: 'tag-1', name: 'Before' })
|
||||
const otherTag = createTag({ id: 'tag-2', name: 'Other' })
|
||||
const knowledgeTag = createTag({
|
||||
id: 'knowledge-tag-1',
|
||||
name: 'Knowledge',
|
||||
type: 'knowledge',
|
||||
})
|
||||
|
||||
queryClient.setQueryData(appListKey, [targetTag, otherTag])
|
||||
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
|
||||
|
||||
const mutationOptions = consoleQuery.tags.update.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
undefined,
|
||||
{
|
||||
params: {
|
||||
tagId: targetTag.id,
|
||||
},
|
||||
body: {
|
||||
name: 'After',
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(appListKey)).toEqual([
|
||||
{
|
||||
...targetTag,
|
||||
name: 'After',
|
||||
},
|
||||
otherTag,
|
||||
])
|
||||
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
|
||||
})
|
||||
|
||||
it('should remove deleted tags across cached list queries', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const appListKey = consoleQuery.tags.list.queryKey({
|
||||
input: {
|
||||
query: {
|
||||
type: 'app',
|
||||
},
|
||||
},
|
||||
})
|
||||
const knowledgeListKey = consoleQuery.tags.list.queryKey({
|
||||
input: {
|
||||
query: {
|
||||
type: 'knowledge',
|
||||
},
|
||||
},
|
||||
})
|
||||
const deletedTag = createTag({ id: 'tag-1', name: 'Delete me' })
|
||||
const remainingTag = createTag({ id: 'tag-2', name: 'Keep me' })
|
||||
const knowledgeTag = createTag({
|
||||
id: 'knowledge-tag-1',
|
||||
name: 'Knowledge',
|
||||
type: 'knowledge',
|
||||
})
|
||||
|
||||
queryClient.setQueryData(appListKey, [deletedTag, remainingTag])
|
||||
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
|
||||
|
||||
const mutationOptions = consoleQuery.tags.delete.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
undefined,
|
||||
{
|
||||
params: {
|
||||
tagId: deletedTag.id,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(appListKey)).toEqual([remainingTag])
|
||||
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { ContractRouterClient } from '@orpc/contract'
|
||||
import type { JsonifiedClient } from '@orpc/openapi-client'
|
||||
import type { RouterUtils } from '@orpc/tanstack-query'
|
||||
import type { Tag } from '@/contract/console/tags'
|
||||
import { createORPCClient, onError } from '@orpc/client'
|
||||
import { OpenAPILink } from '@orpc/openapi-client/fetch'
|
||||
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
|
||||
@ -90,9 +91,57 @@ const consoleLink = new OpenAPILink(consoleRouterContract, {
|
||||
})
|
||||
|
||||
export const consoleClient: JsonifiedClient<ContractRouterClient<typeof consoleRouterContract>> = createORPCClient(consoleLink)
|
||||
|
||||
export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQueryUtils(consoleClient, {
|
||||
path: ['console'],
|
||||
experimental_defaults: {
|
||||
tags: {
|
||||
create: {
|
||||
mutationOptions: {
|
||||
onSuccess: (tag, _variables, _onMutateResult, context) => {
|
||||
context.client.setQueryData(
|
||||
consoleQuery.tags.list.queryKey({
|
||||
input: {
|
||||
query: {
|
||||
type: tag.type,
|
||||
},
|
||||
},
|
||||
}),
|
||||
(oldTags: Tag[] | undefined) => oldTags ? [tag, ...oldTags] : oldTags,
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
update: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_data, variables, _onMutateResult, context) => {
|
||||
context.client.setQueriesData(
|
||||
{
|
||||
queryKey: consoleQuery.tags.list.key({ type: 'query' }),
|
||||
},
|
||||
(oldTags: Tag[] | undefined) => oldTags?.map(tag => tag.id === variables.params.tagId
|
||||
? {
|
||||
...tag,
|
||||
name: variables.body.name,
|
||||
}
|
||||
: tag),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_data, variables, _onMutateResult, context) => {
|
||||
context.client.setQueriesData(
|
||||
{
|
||||
queryKey: consoleQuery.tags.list.key({ type: 'query' }),
|
||||
},
|
||||
(oldTags: Tag[] | undefined) => oldTags?.filter(tag => tag.id !== variables.params.tagId),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
enterprise: {
|
||||
appDeploy: {
|
||||
createAppInstance: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user