Merge branch 'main' into 4-27-app-deploy

This commit is contained in:
Stephen Zhou 2026-05-08 11:43:07 +08:00
commit 2212384a35
No known key found for this signature in database
25 changed files with 1619 additions and 1445 deletions

View File

@ -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

View File

@ -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."

View File

@ -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

View File

@ -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` |

View File

@ -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 {})

View File

@ -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:

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -136,6 +136,10 @@ export default class AudioPlayer {
}
else {
this.isLoadData = true
this.audioContext.resume().then((_) => {
this.audio.play()
this.callback?.('play')
})
this.loadAudio()
}
}

View File

@ -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,
}))

View File

@ -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[] = [

View File

@ -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' },

View File

@ -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) => {

View File

@ -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} />

View File

@ -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)

View File

@ -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',
},
},
})
})
})
})

View File

@ -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,
},
},
}),
})
},
})

View File

@ -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",

View File

@ -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)",

View File

@ -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",

View File

@ -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.",

View File

@ -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",

View File

@ -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])
})
})

View File

@ -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: {