diff --git a/.agents/skills/frontend-query-mutation/SKILL.md b/.agents/skills/frontend-query-mutation/SKILL.md index 49888bdb66..10c49d222e 100644 --- a/.agents/skills/frontend-query-mutation/SKILL.md +++ b/.agents/skills/frontend-query-mutation/SKILL.md @@ -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 diff --git a/.agents/skills/frontend-query-mutation/agents/openai.yaml b/.agents/skills/frontend-query-mutation/agents/openai.yaml index 87f7ae6ea4..79e7e7d214 100644 --- a/.agents/skills/frontend-query-mutation/agents/openai.yaml +++ b/.agents/skills/frontend-query-mutation/agents/openai.yaml @@ -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." diff --git a/.agents/skills/frontend-query-mutation/references/contract-patterns.md b/.agents/skills/frontend-query-mutation/references/contract-patterns.md index 08016ed2cc..25ccfc81d7 100644 --- a/.agents/skills/frontend-query-mutation/references/contract-patterns.md +++ b/.agents/skills/frontend-query-mutation/references/contract-patterns.md @@ -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`. - 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 diff --git a/.agents/skills/frontend-query-mutation/references/runtime-rules.md b/.agents/skills/frontend-query-mutation/references/runtime-rules.md index 73d6fbdded..91b484d438 100644 --- a/.agents/skills/frontend-query-mutation/references/runtime-rules.md +++ b/.agents/skills/frontend-query-mutation/references/runtime-rules.md @@ -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` | diff --git a/web/features/tag-management/__tests__/tag-item-editor.spec.tsx b/web/features/tag-management/__tests__/tag-item-editor.spec.tsx index b9ad877672..39ebd06ae7 100644 --- a/web/features/tag-management/__tests__/tag-item-editor.spec.tsx +++ b/web/features/tag-management/__tests__/tag-item-editor.spec.tsx @@ -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 }) => ({ 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, })) diff --git a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx index 67e6396f35..0c7b1a5323 100644 --- a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx +++ b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx @@ -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 }) => ({ 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[] = [ diff --git a/web/features/tag-management/__tests__/tag-selector.spec.tsx b/web/features/tag-management/__tests__/tag-selector.spec.tsx index 4a976e8781..3b751a7a17 100644 --- a/web/features/tag-management/__tests__/tag-selector.spec.tsx +++ b/web/features/tag-management/__tests__/tag-selector.spec.tsx @@ -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 }) => ({ 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' }, diff --git a/web/features/tag-management/components/tag-item-editor.tsx b/web/features/tag-management/components/tag-item-editor.tsx index 915c325191..edf74196c3 100644 --- a/web/features/tag-management/components/tag-item-editor.tsx +++ b/web/features/tag-management/components/tag-item-editor.tsx @@ -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) => { diff --git a/web/features/tag-management/components/tag-management-modal.tsx b/web/features/tag-management/components/tag-management-modal.tsx index e26f41ed29..05a09519be 100644 --- a/web/features/tag-management/components/tag-management-modal.tsx +++ b/web/features/tag-management/components/tag-management-modal.tsx @@ -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('') const createNewTag = () => { @@ -55,8 +54,8 @@ export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagMan return ( !open && handleClose()}> - -
{t('tag.manageTags', { ns: 'common' })}
+ +
{t('tag.manageTags', { ns: 'common' })}
setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} /> diff --git a/web/features/tag-management/components/tag-selector.tsx b/web/features/tag-management/components/tag-selector.tsx index a20516ed95..70dae241d5 100644 --- a/web/features/tag-management/components/tag-selector.tsx +++ b/web/features/tag-management/components/tag-selector.tsx @@ -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(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) diff --git a/web/features/tag-management/hooks/__tests__/use-tag-mutations.spec.tsx b/web/features/tag-management/hooks/__tests__/use-tag-mutations.spec.tsx index 59051518a8..fd73c38c12 100644 --- a/web/features/tag-management/hooks/__tests__/use-tag-mutations.spec.tsx +++ b/web/features/tag-management/hooks/__tests__/use-tag-mutations.spec.tsx @@ -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 => ({ - 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) => ({ - 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) => ({ - mutationFn: () => Promise.resolve({ result: 'success' }), - ...options, - })) - deleteTagMutationOptions.mockImplementation((options: Record) => ({ - 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(cacheKey, [ - appTag({ id: 'existing-tag', name: 'Existing' }), - ]) - - await act(async () => { - await result.current.mutateAsync({ - body: { - name: 'Created', - type: 'app', - }, - }) - }) - - expect(queryClient.getQueryData(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(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(appCacheKey, [ - appTag({ id: 'tag-1', name: 'Old name' }), - appTag({ id: 'tag-2', name: 'Unchanged' }), - ]) - queryClient.setQueryData(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(appCacheKey)).toEqual([ - appTag({ id: 'tag-1', name: 'Renamed' }), - appTag({ id: 'tag-2', name: 'Unchanged' }), - ]) - expect(queryClient.getQueryData(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(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(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', + }, + }, + }) }) }) }) diff --git a/web/features/tag-management/hooks/use-tag-mutations.ts b/web/features/tag-management/hooks/use-tag-mutations.ts index 9db40561b8..4b0dda9d82 100644 --- a/web/features/tag-management/hooks/use-tag-mutations.ts +++ b/web/features/tag-management/hooks/use-tag-mutations.ts @@ -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, + }, + }, + }), }) }, }) diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index 95bf720bfe..57ca8765e7 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -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 => ({ + 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]) + }) +}) diff --git a/web/service/client.ts b/web/service/client.ts index 65c2b1fc68..1c33423295 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -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> = 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), + ) + }, + }, + }, + }, + }, +})