mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 04:36:31 +08:00
refactor(web): inline tag query defaults (#35883)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
parent
76a7f5f4b9
commit
9a8aa6a0c3
@ -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,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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@ -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,5 +1,6 @@
|
||||
import type { ContractRouterClient } from '@orpc/contract'
|
||||
import type { JsonifiedClient } from '@orpc/openapi-client'
|
||||
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'
|
||||
@ -84,4 +85,56 @@ const consoleLink = new OpenAPILink(consoleRouterContract, {
|
||||
})
|
||||
|
||||
export const consoleClient: JsonifiedClient<ContractRouterClient<typeof consoleRouterContract>> = createORPCClient(consoleLink)
|
||||
export const consoleQuery = createTanstackQueryUtils(consoleClient, { path: ['console'] })
|
||||
|
||||
export const consoleQuery = 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),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user