mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
feat(knowledge-fs): support tag management and filtering (WTA-1980)
This commit is contained in:
parent
1fe9b401e9
commit
30c84ce58c
@ -151,6 +151,8 @@ import {
|
||||
zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesResponse,
|
||||
zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPath,
|
||||
zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdResponse,
|
||||
zGetKnowledgeFsSpacesByControlSpaceIdTagsPath,
|
||||
zGetKnowledgeFsSpacesByControlSpaceIdTagsResponse,
|
||||
zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsPath,
|
||||
zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsQuery,
|
||||
zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsResponse,
|
||||
@ -327,6 +329,9 @@ import {
|
||||
zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyBody,
|
||||
zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyPath,
|
||||
zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponse,
|
||||
zPutKnowledgeFsSpacesByControlSpaceIdTagsBody,
|
||||
zPutKnowledgeFsSpacesByControlSpaceIdTagsPath,
|
||||
zPutKnowledgeFsSpacesByControlSpaceIdTagsResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
export const get = oc
|
||||
@ -2299,6 +2304,38 @@ export const sources = {
|
||||
}
|
||||
|
||||
export const get45 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getKnowledgeFsSpacesByControlSpaceIdTags',
|
||||
path: '/knowledge-fs/spaces/{control_space_id}/tags',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetKnowledgeFsSpacesByControlSpaceIdTagsPath }))
|
||||
.output(zGetKnowledgeFsSpacesByControlSpaceIdTagsResponse)
|
||||
|
||||
export const put5 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'putKnowledgeFsSpacesByControlSpaceIdTags',
|
||||
path: '/knowledge-fs/spaces/{control_space_id}/tags',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPutKnowledgeFsSpacesByControlSpaceIdTagsBody,
|
||||
params: zPutKnowledgeFsSpacesByControlSpaceIdTagsPath,
|
||||
}),
|
||||
)
|
||||
.output(zPutKnowledgeFsSpacesByControlSpaceIdTagsResponse)
|
||||
|
||||
export const tags = {
|
||||
get: get45,
|
||||
put: put5,
|
||||
}
|
||||
|
||||
export const get46 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -2315,10 +2352,10 @@ export const get45 = oc
|
||||
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdConflictsResponse)
|
||||
|
||||
export const conflicts = {
|
||||
get: get45,
|
||||
get: get46,
|
||||
}
|
||||
|
||||
export const get46 = oc
|
||||
export const get47 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -2335,10 +2372,10 @@ export const get46 = oc
|
||||
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdEvidenceResponse)
|
||||
|
||||
export const evidence = {
|
||||
get: get46,
|
||||
get: get47,
|
||||
}
|
||||
|
||||
export const get47 = oc
|
||||
export const get48 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -2355,10 +2392,10 @@ export const get47 = oc
|
||||
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdMissingResponse)
|
||||
|
||||
export const missing = {
|
||||
get: get47,
|
||||
get: get48,
|
||||
}
|
||||
|
||||
export const get48 = oc
|
||||
export const get49 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -2370,13 +2407,13 @@ export const get48 = oc
|
||||
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesByTraceIdResponse)
|
||||
|
||||
export const byTraceId = {
|
||||
get: get48,
|
||||
get: get49,
|
||||
conflicts,
|
||||
evidence,
|
||||
missing,
|
||||
}
|
||||
|
||||
export const get49 = oc
|
||||
export const get50 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -2393,7 +2430,7 @@ export const get49 = oc
|
||||
.output(zGetKnowledgeFsSpacesByControlSpaceIdTracesResponse)
|
||||
|
||||
export const traces = {
|
||||
get: get49,
|
||||
get: get50,
|
||||
byTraceId,
|
||||
}
|
||||
|
||||
@ -2531,7 +2568,7 @@ export const delete13 = oc
|
||||
.input(z.object({ params: zDeleteKnowledgeFsSpacesByControlSpaceIdPath }))
|
||||
.output(zDeleteKnowledgeFsSpacesByControlSpaceIdResponse)
|
||||
|
||||
export const get50 = oc
|
||||
export const get51 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -2560,7 +2597,7 @@ export const patch9 = oc
|
||||
|
||||
export const byControlSpaceId = {
|
||||
delete: delete13,
|
||||
get: get50,
|
||||
get: get51,
|
||||
patch: patch9,
|
||||
appBindings,
|
||||
backgroundTasks,
|
||||
@ -2584,11 +2621,12 @@ export const byControlSpaceId = {
|
||||
sourceProviders,
|
||||
sourceWorkflows,
|
||||
sources,
|
||||
tags,
|
||||
traces,
|
||||
uploadSessions,
|
||||
}
|
||||
|
||||
export const get51 = oc
|
||||
export const get52 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@ -2612,7 +2650,7 @@ export const post40 = oc
|
||||
.output(zPostKnowledgeFsSpacesResponse)
|
||||
|
||||
export const spaces = {
|
||||
get: get51,
|
||||
get: get52,
|
||||
post: post40,
|
||||
byControlSpaceId,
|
||||
}
|
||||
|
||||
@ -1084,6 +1084,14 @@ export type KnowledgeFsSourceWorkflowImportPayload =
|
||||
kind: 'online-drive-import'
|
||||
} & KnowledgeFsOnlineDriveWorkflowImportPayload)
|
||||
|
||||
export type KnowledgeFsSpaceTagListResponse = {
|
||||
data: Array<KnowledgeFsSpaceTagResponse>
|
||||
}
|
||||
|
||||
export type KnowledgeFsSpaceTagsReplacePayload = {
|
||||
tag_ids?: Array<string>
|
||||
}
|
||||
|
||||
export type KnowledgeFsTraceListResponse = {
|
||||
data: Array<KnowledgeFsTraceResponse>
|
||||
next_cursor?: string | null
|
||||
@ -1220,6 +1228,7 @@ export type KnowledgeFsSpaceListItemResponse = {
|
||||
permission_keys: Array<KnowledgeFsProductPermission>
|
||||
resource_version: number
|
||||
state: KnowledgeFsControlSpaceState
|
||||
tags?: Array<KnowledgeFsSpaceTagResponse>
|
||||
technical_status: 'available' | 'not_ready' | 'unavailable'
|
||||
technical_summary?: KnowledgeFsTechnicalSummary | null
|
||||
updated_at: string
|
||||
@ -1905,6 +1914,12 @@ export type KnowledgeFsOnlineDriveWorkflowImportPayload = {
|
||||
kind: 'online-drive-import'
|
||||
}
|
||||
|
||||
export type KnowledgeFsSpaceTagResponse = {
|
||||
id: string
|
||||
name: string
|
||||
type?: 'knowledge'
|
||||
}
|
||||
|
||||
export type KnowledgeFsTraceResponse = {
|
||||
completed: boolean
|
||||
created_at: string
|
||||
@ -2241,6 +2256,7 @@ export type GetKnowledgeFsSpacesData = {
|
||||
limit?: number
|
||||
page?: number
|
||||
query?: string
|
||||
tag_ids?: Array<string>
|
||||
}
|
||||
url: '/knowledge-fs/spaces'
|
||||
}
|
||||
@ -4012,6 +4028,38 @@ export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImport
|
||||
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponse =
|
||||
PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponses]
|
||||
|
||||
export type GetKnowledgeFsSpacesByControlSpaceIdTagsData = {
|
||||
body?: never
|
||||
path: {
|
||||
control_space_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/knowledge-fs/spaces/{control_space_id}/tags'
|
||||
}
|
||||
|
||||
export type GetKnowledgeFsSpacesByControlSpaceIdTagsResponses = {
|
||||
200: KnowledgeFsSpaceTagListResponse
|
||||
}
|
||||
|
||||
export type GetKnowledgeFsSpacesByControlSpaceIdTagsResponse =
|
||||
GetKnowledgeFsSpacesByControlSpaceIdTagsResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdTagsResponses]
|
||||
|
||||
export type PutKnowledgeFsSpacesByControlSpaceIdTagsData = {
|
||||
body: KnowledgeFsSpaceTagsReplacePayload
|
||||
path: {
|
||||
control_space_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/knowledge-fs/spaces/{control_space_id}/tags'
|
||||
}
|
||||
|
||||
export type PutKnowledgeFsSpacesByControlSpaceIdTagsResponses = {
|
||||
200: KnowledgeFsSpaceTagListResponse
|
||||
}
|
||||
|
||||
export type PutKnowledgeFsSpacesByControlSpaceIdTagsResponse =
|
||||
PutKnowledgeFsSpacesByControlSpaceIdTagsResponses[keyof PutKnowledgeFsSpacesByControlSpaceIdTagsResponses]
|
||||
|
||||
export type GetKnowledgeFsSpacesByControlSpaceIdTracesData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@ -549,6 +549,13 @@ export const zKnowledgeFsSourceSyncPolicyPayload = z.object({
|
||||
mode: z.enum(['custom', 'interval', 'manual', 'provider']),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSSpaceTagsReplacePayload
|
||||
*/
|
||||
export const zKnowledgeFsSpaceTagsReplacePayload = z.object({
|
||||
tag_ids: z.array(z.string()).max(100).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSUploadSessionCreatePayload
|
||||
*/
|
||||
@ -867,34 +874,6 @@ export const zKnowledgeFsSpaceDetailResponse = z.object({
|
||||
visibility: zKnowledgeFsControlSpaceVisibility,
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSSpaceListItemResponse
|
||||
*/
|
||||
export const zKnowledgeFsSpaceListItemResponse = z.object({
|
||||
control_space_id: z.string(),
|
||||
created_at: z.iso.datetime(),
|
||||
knowledge_space_id: z.string().nullable(),
|
||||
linked_apps: z.int().gte(0),
|
||||
owner_account_id: z.string(),
|
||||
permission_keys: z.array(zKnowledgeFsProductPermission),
|
||||
resource_version: z.int(),
|
||||
state: zKnowledgeFsControlSpaceState,
|
||||
technical_status: z.enum(['available', 'not_ready', 'unavailable']),
|
||||
technical_summary: zKnowledgeFsTechnicalSummary.nullish(),
|
||||
updated_at: z.iso.datetime(),
|
||||
visibility: zKnowledgeFsControlSpaceVisibility,
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSSpaceListResponse
|
||||
*/
|
||||
export const zKnowledgeFsSpaceListResponse = z.object({
|
||||
data: z.array(zKnowledgeFsSpaceListItemResponse),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSAppSpaceJoinType
|
||||
*/
|
||||
@ -1997,6 +1976,51 @@ export const zKnowledgeFsSourceImportFilesPayload = z.object({
|
||||
files: z.array(zKnowledgeFsSourceImportFilePayload).min(1).max(200),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSSpaceTagResponse
|
||||
*/
|
||||
export const zKnowledgeFsSpaceTagResponse = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.literal('knowledge').optional().default('knowledge'),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSSpaceTagListResponse
|
||||
*/
|
||||
export const zKnowledgeFsSpaceTagListResponse = z.object({
|
||||
data: z.array(zKnowledgeFsSpaceTagResponse),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSSpaceListItemResponse
|
||||
*/
|
||||
export const zKnowledgeFsSpaceListItemResponse = z.object({
|
||||
control_space_id: z.string(),
|
||||
created_at: z.iso.datetime(),
|
||||
knowledge_space_id: z.string().nullable(),
|
||||
linked_apps: z.int().gte(0),
|
||||
owner_account_id: z.string(),
|
||||
permission_keys: z.array(zKnowledgeFsProductPermission),
|
||||
resource_version: z.int(),
|
||||
state: zKnowledgeFsControlSpaceState,
|
||||
tags: z.array(zKnowledgeFsSpaceTagResponse).optional(),
|
||||
technical_status: z.enum(['available', 'not_ready', 'unavailable']),
|
||||
technical_summary: zKnowledgeFsTechnicalSummary.nullish(),
|
||||
updated_at: z.iso.datetime(),
|
||||
visibility: zKnowledgeFsControlSpaceVisibility,
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSSpaceListResponse
|
||||
*/
|
||||
export const zKnowledgeFsSpaceListResponse = z.object({
|
||||
data: z.array(zKnowledgeFsSpaceListItemResponse),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSAnswerTraceStepResponse
|
||||
*/
|
||||
@ -2837,6 +2861,7 @@ export const zGetKnowledgeFsSpacesQuery = z.object({
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
query: z.string().max(255).optional(),
|
||||
tag_ids: z.array(z.string().min(1).max(255)).max(100).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -4182,6 +4207,26 @@ export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImpo
|
||||
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponse =
|
||||
zKnowledgeFsSourceWorkflowResponse
|
||||
|
||||
export const zGetKnowledgeFsSpacesByControlSpaceIdTagsPath = z.object({
|
||||
control_space_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFS space tags
|
||||
*/
|
||||
export const zGetKnowledgeFsSpacesByControlSpaceIdTagsResponse = zKnowledgeFsSpaceTagListResponse
|
||||
|
||||
export const zPutKnowledgeFsSpacesByControlSpaceIdTagsBody = zKnowledgeFsSpaceTagsReplacePayload
|
||||
|
||||
export const zPutKnowledgeFsSpacesByControlSpaceIdTagsPath = z.object({
|
||||
control_space_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFS space tags replaced
|
||||
*/
|
||||
export const zPutKnowledgeFsSpacesByControlSpaceIdTagsResponse = zKnowledgeFsSpaceTagListResponse
|
||||
|
||||
export const zGetKnowledgeFsSpacesByControlSpaceIdTracesPath = z.object({
|
||||
control_space_id: z.string(),
|
||||
})
|
||||
|
||||
@ -18,6 +18,7 @@ type KnowledgeSpaceList = {
|
||||
revision: number
|
||||
slug: string
|
||||
tenantId: string
|
||||
tags?: Array<{ id: string; name: string }>
|
||||
updatedAt: string
|
||||
documentCount?: number
|
||||
}>
|
||||
@ -33,6 +34,7 @@ type ListKnowledgeSpacesInfiniteOptions = {
|
||||
limit: number
|
||||
page: number
|
||||
query?: string
|
||||
tag_ids?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -57,6 +59,7 @@ const knowledgeSpaceApiResponse = vi.hoisted(
|
||||
permission_keys: space.permissionKeys ?? ['knowledge_space_read'],
|
||||
resource_version: space.revision,
|
||||
state: 'active',
|
||||
tags: space.tags?.map((tag) => ({ ...tag, type: 'knowledge' as const })),
|
||||
technical_status: 'available',
|
||||
technical_summary: {
|
||||
description: space.description ?? null,
|
||||
@ -213,6 +216,35 @@ vi.mock('@/features/system-features/state', () => ({
|
||||
knowledgeFsUploadEnabledAtom: systemFeaturesStateMock.knowledgeFsUploadEnabledAtom,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/tag-management/components/tag-filter', () => ({
|
||||
TagFilter: ({ onChange, value }: { onChange: (value: string[]) => void; value: string[] }) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="dataset.newKnowledge.tags"
|
||||
onClick={() => onChange(value.length ? [] : ['tag-1', 'tag-2'])}
|
||||
>
|
||||
dataset.newKnowledge.tags
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/tag-management/components/tag-management-modal', () => ({
|
||||
TagManagementModal: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../components/knowledge-space-card-tags', () => ({
|
||||
KnowledgeSpaceCardTags: ({
|
||||
knowledgeSpace,
|
||||
}: {
|
||||
knowledgeSpace: { tags?: Array<{ id: string; name: string }> }
|
||||
}) => (
|
||||
<div>
|
||||
<span>dataset.newKnowledge.tags</span>
|
||||
{knowledgeSpace.tags?.map((tag) => tag.name).join(', ')}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/account-profile/client', () => ({
|
||||
userProfileQueryOptions: () => ({}),
|
||||
}))
|
||||
@ -308,6 +340,10 @@ describe('NewKnowledgeList', () => {
|
||||
name: 'Support knowledge',
|
||||
revision: 1,
|
||||
slug: 'support-knowledge',
|
||||
tags: [
|
||||
{ id: 'tag-1', name: 'Customer support' },
|
||||
{ id: 'tag-2', name: 'Public docs' },
|
||||
],
|
||||
tenantId: 'tenant-1',
|
||||
updatedAt: '2026-07-18T00:00:00Z',
|
||||
},
|
||||
@ -327,6 +363,8 @@ describe('NewKnowledgeList', () => {
|
||||
const supportCard = within(list).getByRole('link', {
|
||||
name: 'Support knowledge',
|
||||
})
|
||||
const supportCardItem = supportCard.closest('li')
|
||||
expect(supportCardItem).not.toBeNull()
|
||||
expect(supportCard).toHaveAttribute('href', '/datasets/new/space-1')
|
||||
expect(supportCard).toBeInTheDocument()
|
||||
expect(
|
||||
@ -339,7 +377,8 @@ describe('NewKnowledgeList', () => {
|
||||
expect(within(supportCard).getByLabelText('camera')).toBeInTheDocument()
|
||||
expect(within(list).getAllByText('dataset.newKnowledge.cardType')).toHaveLength(2)
|
||||
expect(within(list).getAllByText('dataset.newKnowledge.tags')).toHaveLength(2)
|
||||
expect(within(supportCard).getByText('12')).toBeInTheDocument()
|
||||
expect(within(list).getByText('Customer support, Public docs')).toBeInTheDocument()
|
||||
expect(within(supportCardItem!).getByText('12')).toBeInTheDocument()
|
||||
expect(supportCard).toHaveAccessibleDescription('dataset.newKnowledge.overview.linkedApps: 0')
|
||||
expect(
|
||||
within(list).getByRole('link', { name: 'Engineering handbook' }),
|
||||
@ -453,7 +492,7 @@ describe('NewKnowledgeList', () => {
|
||||
expect(toastMock.success).toHaveBeenCalledWith('dataset.datasetDeleted')
|
||||
})
|
||||
|
||||
it('keeps backend-dependent metadata filters interactive and sends search to the collection API', async () => {
|
||||
it('syncs tag filters to the URL and collection API while keeping search interactive', async () => {
|
||||
const user = userEvent.setup()
|
||||
setResolvedPage([
|
||||
{
|
||||
@ -493,13 +532,23 @@ describe('NewKnowledgeList', () => {
|
||||
expect(tags).toBeEnabled()
|
||||
expect(creators).toBeEnabled()
|
||||
await user.click(tags)
|
||||
expect(toastMock.info).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable')
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tag_ids')).toBe('tag-1;tag-2')
|
||||
})
|
||||
let options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
|
||||
expect(options?.input(1)).toEqual({
|
||||
query: { limit: 30, page: 1, tag_ids: ['tag-1', 'tag-2'] },
|
||||
})
|
||||
await user.click(tags)
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('tag_ids')).toBe(false)
|
||||
})
|
||||
expect(search).toBeEnabled()
|
||||
expect(create).toHaveAttribute('href', '/datasets/new/create')
|
||||
|
||||
await user.type(search, 'customer support')
|
||||
await waitFor(() => {
|
||||
const options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
|
||||
options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
|
||||
expect(options?.input(1)).toEqual({
|
||||
query: { limit: 30, page: 1, query: 'customer support' },
|
||||
})
|
||||
@ -507,6 +556,19 @@ describe('NewKnowledgeList', () => {
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('query')).toBe('customer support')
|
||||
})
|
||||
|
||||
it('restores tag filters from the URL and sends match-any IDs to the collection API', () => {
|
||||
setResolvedPage()
|
||||
|
||||
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />, {
|
||||
searchParams: '?tag_ids=tag-1%3Btag-2',
|
||||
})
|
||||
|
||||
const options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
|
||||
expect(options?.input(1)).toEqual({
|
||||
query: { limit: 30, page: 1, tag_ids: ['tag-1', 'tag-2'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('restores server search from the URL and shows its empty state', async () => {
|
||||
const user = userEvent.setup()
|
||||
setResolvedPage()
|
||||
|
||||
@ -0,0 +1,203 @@
|
||||
import type { KnowledgeFsSpaceListItemResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '@/test/console/render'
|
||||
import { KnowledgeSpaceCardTags } from '../knowledge-space-card-tags'
|
||||
|
||||
const { invalidateQueries, putTags, toastMock } = vi.hoisted(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
putTags: vi.fn(),
|
||||
toastMock: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const workspacePermissionKeys = vi.hoisted(() => ({
|
||||
value: ['dataset.tag.manage'] as string[],
|
||||
}))
|
||||
|
||||
const tagQuery = vi.hoisted(() => ({
|
||||
data: [] as Tag[],
|
||||
}))
|
||||
|
||||
const knowledgeTags: Tag[] = [
|
||||
{ binding_count: '1', id: 'tag-1', name: 'Frontend', type: 'knowledge' },
|
||||
{ binding_count: '1', id: 'tag-2', name: 'Backend', type: 'knowledge' },
|
||||
{ binding_count: '0', id: 'tag-3', name: 'Public docs', type: 'knowledge' },
|
||||
]
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMock }))
|
||||
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createPermissionStateModuleMock(() => ({
|
||||
workspacePermissionKeys: workspacePermissionKeys.value,
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/features/tag-management/hooks/use-tag-mutations', () => ({
|
||||
useApplyTagBindingsMutation: () => ({ mutate: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...original,
|
||||
useMutation: (options: {
|
||||
mutationFn?: (input: unknown) => Promise<unknown>
|
||||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
onSuccess?: () => void
|
||||
}) => ({
|
||||
isPending: false,
|
||||
mutate: (input: unknown) => {
|
||||
Promise.resolve(options.mutationFn?.(input))
|
||||
.then(
|
||||
() => options.onSuccess?.(),
|
||||
() => options.onError?.(),
|
||||
)
|
||||
.finally(() => options.onSettled?.())
|
||||
},
|
||||
}),
|
||||
useQuery: () => ({ data: tagQuery.data }),
|
||||
useQueryClient: () => ({ invalidateQueries }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
knowledgeFs: {
|
||||
spaces: {
|
||||
byControlSpaceId: {
|
||||
tags: {
|
||||
put: {
|
||||
mutationOptions: () => ({ mutationFn: putTags }),
|
||||
},
|
||||
},
|
||||
},
|
||||
get: {
|
||||
key: () => ['knowledge-fs', 'spaces'],
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
get: {
|
||||
key: () => ['tags'],
|
||||
queryOptions: () => ({}),
|
||||
},
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: vi.fn() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function createKnowledgeSpace(
|
||||
permissionKeys: KnowledgeFsSpaceListItemResponse['permission_keys'],
|
||||
): KnowledgeFsSpaceListItemResponse {
|
||||
return {
|
||||
control_space_id: 'space-1',
|
||||
created_at: '2026-08-13T00:00:00Z',
|
||||
knowledge_space_id: 'knowledge-space-1',
|
||||
linked_apps: 0,
|
||||
owner_account_id: 'account-1',
|
||||
permission_keys: permissionKeys,
|
||||
resource_version: 1,
|
||||
state: 'active',
|
||||
tags: [
|
||||
{ id: 'tag-1', name: 'Frontend', type: 'knowledge' },
|
||||
{ id: 'tag-2', name: 'Backend', type: 'knowledge' },
|
||||
],
|
||||
technical_status: 'available',
|
||||
updated_at: '2026-08-13T00:00:00Z',
|
||||
visibility: 'only_me',
|
||||
}
|
||||
}
|
||||
|
||||
describe('KnowledgeSpaceCardTags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
putTags.mockResolvedValue({ data: [] })
|
||||
invalidateQueries.mockResolvedValue(undefined)
|
||||
workspacePermissionKeys.value = ['dataset.tag.manage']
|
||||
tagQuery.data = knowledgeTags
|
||||
})
|
||||
|
||||
it('shows tags returned with the knowledge space', () => {
|
||||
tagQuery.data = []
|
||||
render(
|
||||
<KnowledgeSpaceCardTags
|
||||
knowledgeSpace={createKnowledgeSpace(['knowledge_space_read'])}
|
||||
onOpenTagManagement={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Frontend')).toBeInTheDocument()
|
||||
expect(screen.getByText('Backend')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the final tag set and refreshes the list and binding counts', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<KnowledgeSpaceCardTags
|
||||
knowledgeSpace={createKnowledgeSpace(['knowledge_space_edit', 'knowledge_space_read'])}
|
||||
onOpenTagManagement={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('combobox', { name: 'Frontend, Backend' })
|
||||
await user.click(trigger)
|
||||
await user.click(await screen.findByRole('option', { name: 'Frontend' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Public docs' }))
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(putTags).toHaveBeenCalledWith({
|
||||
body: { tag_ids: ['tag-2', 'tag-3'] },
|
||||
params: { control_space_id: 'space-1' },
|
||||
})
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['knowledge-fs', 'spaces'],
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['tags'] })
|
||||
expect(toastMock.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
|
||||
})
|
||||
|
||||
it('can clear every tag from an editable knowledge space', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<KnowledgeSpaceCardTags
|
||||
knowledgeSpace={createKnowledgeSpace(['knowledge_space_edit', 'knowledge_space_read'])}
|
||||
onOpenTagManagement={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('combobox', { name: 'Frontend, Backend' })
|
||||
await user.click(trigger)
|
||||
await user.click(await screen.findByRole('option', { name: 'Frontend' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Backend' }))
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(putTags).toHaveBeenCalledWith({
|
||||
body: { tag_ids: [] },
|
||||
params: { control_space_id: 'space-1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not allow binding changes without space edit permission', () => {
|
||||
render(
|
||||
<KnowledgeSpaceCardTags
|
||||
knowledgeSpace={createKnowledgeSpace(['knowledge_space_read'])}
|
||||
onOpenTagManagement={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Frontend, Backend' })).toBeDisabled()
|
||||
expect(putTags).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeFsSpaceListItemResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
|
||||
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TagSelector } from '@/features/tag-management/components/tag-selector'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export function KnowledgeSpaceCardTags({
|
||||
knowledgeSpace,
|
||||
onOpenTagManagement,
|
||||
}: {
|
||||
knowledgeSpace: KnowledgeFsSpaceListItemResponse
|
||||
onOpenTagManagement: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const canEdit = knowledgeSpace.permission_keys.includes('knowledge_space_edit')
|
||||
const tags = useMemo<Tag[]>(
|
||||
() =>
|
||||
(knowledgeSpace.tags ?? []).map((tag) => ({
|
||||
binding_count: '',
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
type: 'knowledge',
|
||||
})),
|
||||
[knowledgeSpace.tags],
|
||||
)
|
||||
const replaceTagsMutation = useMutation({
|
||||
...consoleQuery.knowledgeFs.spaces.byControlSpaceId.tags.put.mutationOptions(),
|
||||
onError: () => toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'])),
|
||||
onSuccess: () => toast.success(t(($) => $['actionMsg.modifiedSuccessfully'])),
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.knowledgeFs.spaces.get.key(),
|
||||
})
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.tags.get.key({
|
||||
type: 'query',
|
||||
input: { query: { type: 'knowledge' } },
|
||||
}),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<TagSelector
|
||||
type="knowledge"
|
||||
targetId={knowledgeSpace.control_space_id}
|
||||
value={tags}
|
||||
canBindOrUnbindTags={canEdit}
|
||||
requiresTargetEditPermission
|
||||
showProvidedTagNames
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onApplyTags={(tagIds) =>
|
||||
replaceTagsMutation.mutate({
|
||||
params: { control_space_id: knowledgeSpace.control_space_id },
|
||||
body: { tag_ids: tagIds },
|
||||
})
|
||||
}
|
||||
className="relative z-1 mx-3 w-auto"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import Link from '@/next/link'
|
||||
import { newKnowledgeOverviewPath } from '../routes'
|
||||
import { KnowledgeSpaceActions } from './knowledge-space-actions'
|
||||
import { KnowledgeSpaceCardTags } from './knowledge-space-card-tags'
|
||||
import { KnowledgeSpaceIcon } from './knowledge-space-icon'
|
||||
|
||||
function getBuiltinIconName(iconRef: string | undefined) {
|
||||
@ -14,13 +15,14 @@ function getBuiltinIconName(iconRef: string | undefined) {
|
||||
|
||||
export function KnowledgeSpaceCard({
|
||||
knowledgeSpace,
|
||||
onOpenTagManagement,
|
||||
}: {
|
||||
knowledgeSpace: KnowledgeFsSpaceListItemResponse
|
||||
onOpenTagManagement: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const linkedAppsDescriptionId = useId()
|
||||
const unavailable = t(($) => $['cornerLabel.unavailable'])
|
||||
const summary = knowledgeSpace.technical_summary
|
||||
const name = summary?.name ?? knowledgeSpace.control_space_id
|
||||
const linkedApps = knowledgeSpace.linked_apps
|
||||
@ -31,14 +33,14 @@ export function KnowledgeSpaceCard({
|
||||
: formatTimeFromNow(updatedAt)
|
||||
|
||||
return (
|
||||
<li className="group relative">
|
||||
<li className="group relative flex h-41.5 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs outline-hidden transition-shadow hover:shadow-md motion-reduce:transition-none">
|
||||
<Link
|
||||
href={newKnowledgeOverviewPath(knowledgeSpace.control_space_id)}
|
||||
aria-label={name}
|
||||
aria-describedby={linkedAppsDescriptionId}
|
||||
className="relative flex h-41.5 w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs outline-hidden transition-shadow hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
|
||||
className="block outline-hidden after:absolute after:inset-0 after:z-0 after:rounded-xl after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-state-accent-solid"
|
||||
>
|
||||
<div className="flex w-full items-center gap-3 px-4 pt-4 pb-1.5">
|
||||
<div className="relative z-1 flex w-full items-center gap-3 px-4 pt-4 pb-1.5">
|
||||
<div
|
||||
aria-label={iconName ?? t(($) => $['newKnowledge.cardType'])}
|
||||
title={iconName}
|
||||
@ -53,40 +55,35 @@ export function KnowledgeSpaceCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="line-clamp-2 min-h-8 w-full px-4 py-0.5 body-xs-regular text-text-tertiary">
|
||||
<p className="relative z-1 line-clamp-2 min-h-8 w-full px-4 py-0.5 body-xs-regular text-text-tertiary">
|
||||
{summary?.description || t(($) => $['newKnowledge.noDescription'])}
|
||||
</p>
|
||||
<div
|
||||
aria-label={`${t(($) => $['newKnowledge.tags'])}. ${unavailable}`}
|
||||
className="mt-1 flex min-w-0 items-center gap-1 px-4"
|
||||
>
|
||||
<span className="rounded-md bg-background-section px-1.5 py-0.5 system-2xs-medium-uppercase text-text-disabled">
|
||||
{t(($) => $['newKnowledge.tags'])}
|
||||
</span>
|
||||
<span className="system-2xs-regular text-text-disabled">{unavailable}</span>
|
||||
</div>
|
||||
<div className="mt-auto flex w-full min-w-0 items-center gap-2 px-4 pt-1 pb-2.5 system-xs-regular text-text-tertiary">
|
||||
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
|
||||
<span aria-hidden className="i-ri-file-text-line size-3.5" />
|
||||
<span>{summary?.document_count ?? 0}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
|
||||
<span aria-hidden className="i-ri-robot-2-line size-3.5" />
|
||||
<span aria-hidden>{linkedApps}</span>
|
||||
<span id={linkedAppsDescriptionId} className="sr-only">
|
||||
{t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden className="text-divider-deep">
|
||||
/
|
||||
</span>
|
||||
<span className="ml-auto min-w-0 truncate text-right">
|
||||
{t(($) => $['newKnowledge.updated'], {
|
||||
date: formattedUpdatedAt,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<KnowledgeSpaceCardTags
|
||||
knowledgeSpace={knowledgeSpace}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
/>
|
||||
<div className="pointer-events-none relative z-1 mt-auto flex w-full min-w-0 items-center gap-2 px-4 pt-1 pb-2.5 system-xs-regular text-text-tertiary">
|
||||
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
|
||||
<span aria-hidden className="i-ri-file-text-line size-3.5" />
|
||||
<span>{summary?.document_count ?? 0}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-text-disabled">
|
||||
<span aria-hidden className="i-ri-robot-2-line size-3.5" />
|
||||
<span aria-hidden>{linkedApps}</span>
|
||||
<span id={linkedAppsDescriptionId} className="sr-only">
|
||||
{t(($) => $['newKnowledge.overview.linkedApps'])}: {linkedApps}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden className="text-divider-deep">
|
||||
/
|
||||
</span>
|
||||
<span className="ml-auto min-w-0 truncate text-right">
|
||||
{t(($) => $['newKnowledge.updated'], {
|
||||
date: formattedUpdatedAt,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<KnowledgeSpaceActions knowledgeSpace={knowledgeSpace} />
|
||||
</li>
|
||||
)
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { createParser, parseAsString, useQueryState } from 'nuqs'
|
||||
@ -13,6 +12,8 @@ import ExternalAPIPanel from '@/app/components/datasets/external-api/external-ap
|
||||
import ServiceApi from '@/app/components/datasets/extra-info/service-api'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { knowledgeFsUploadEnabledAtom } from '@/features/system-features/state'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
|
||||
@ -32,6 +33,8 @@ import {
|
||||
} from './components/new-knowledge-list-states'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
const TAG_FILTER_MAX_ID_LENGTH = 255
|
||||
const TAG_FILTER_MAX_SELECTION = 100
|
||||
const searchParser = parseAsString.withDefault('').withOptions({
|
||||
history: 'replace',
|
||||
})
|
||||
@ -51,6 +54,21 @@ const creatorIdsParser = createParser<string[]>({
|
||||
.withDefault([])
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
function normalizeTagIds(tagIds: string[]) {
|
||||
return [...new Set(tagIds)]
|
||||
.filter((tagId) => tagId.length > 0 && tagId.length <= TAG_FILTER_MAX_ID_LENGTH)
|
||||
.slice(0, TAG_FILTER_MAX_SELECTION)
|
||||
}
|
||||
|
||||
const tagIdsParser = createParser<string[]>({
|
||||
eq: (left, right) =>
|
||||
left.length === right.length && left.every((tagId, index) => tagId === right[index]),
|
||||
parse: (query) => normalizeTagIds(query.split(';')),
|
||||
serialize: (tagIds) => normalizeTagIds(tagIds).join(';'),
|
||||
})
|
||||
.withDefault([])
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
function isUnavailableError(error: unknown) {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const status = 'status' in error ? error.status : undefined
|
||||
@ -62,19 +80,6 @@ function isUnavailableError(error: unknown) {
|
||||
return dataStatus === 404 || dataStatus === 503
|
||||
}
|
||||
|
||||
function MetadataFilter({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="gap-0 border-0 bg-components-input-bg-normal px-2 font-normal text-text-tertiary shadow-none hover:bg-components-input-bg-hover"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="px-1 system-sm-regular">{label}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line size-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NewKnowledgeList({
|
||||
view,
|
||||
onViewChange,
|
||||
@ -84,18 +89,19 @@ export function NewKnowledgeList({
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
|
||||
const [showExternalApiPanel, setShowExternalApiPanel] = useState(false)
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const uploadAvailable = useAtomValue(knowledgeFsUploadEnabledAtom)
|
||||
const canCreate = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
|
||||
const canConnect = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
|
||||
const filtersUnavailable = t(($) => $['newKnowledge.filtersUnavailable'])
|
||||
const showFilterBoundary = () => toast.info(filtersUnavailable)
|
||||
const createLabel = tCommon(($) => $['operation.create'])
|
||||
const [searchValue, setSearchValue] = useQueryState('query', searchParser)
|
||||
const debouncedSearchValue = useDebounce(searchValue.trim(), { wait: 300 })
|
||||
const [creatorIds, setCreatorIds] = useQueryState('creator_ids', creatorIdsParser)
|
||||
const [tagIds, setTagIds] = useQueryState('tag_ids', tagIdsParser)
|
||||
const knowledgeSpacesQuery = useInfiniteQuery(
|
||||
consoleQuery.knowledgeFs.spaces.get.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
@ -103,6 +109,7 @@ export function NewKnowledgeList({
|
||||
limit: PAGE_SIZE,
|
||||
page: pageParam,
|
||||
...(creatorIds.length > 0 ? { creator_ids: creatorIds } : {}),
|
||||
...(tagIds.length > 0 ? { tag_ids: tagIds } : {}),
|
||||
...(debouncedSearchValue ? { query: debouncedSearchValue } : {}),
|
||||
},
|
||||
}),
|
||||
@ -145,7 +152,12 @@ export function NewKnowledgeList({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<MetadataFilter label={t(($) => $['newKnowledge.tags'])} onClick={showFilterBoundary} />
|
||||
<TagFilter
|
||||
type="knowledge"
|
||||
value={tagIds}
|
||||
onChange={(nextTagIds) => void setTagIds(nextTagIds)}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
<CreatorFilter
|
||||
value={creatorIds}
|
||||
onChange={(nextCreatorIds) => void setCreatorIds(nextCreatorIds)}
|
||||
@ -209,7 +221,7 @@ export function NewKnowledgeList({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : knowledgeSpaces.length === 0 && creatorIds.length === 0 ? (
|
||||
) : knowledgeSpaces.length === 0 && creatorIds.length === 0 && tagIds.length === 0 ? (
|
||||
<NewKnowledgeEmptyState
|
||||
canConnect={canConnect}
|
||||
canCreate={canCreate}
|
||||
@ -218,7 +230,7 @@ export function NewKnowledgeList({
|
||||
) : knowledgeSpaces.length === 0 ? (
|
||||
<div className="flex min-h-105 items-center justify-center px-6 text-center text-text-tertiary">
|
||||
{tCommon(($) => $['operation.noSearchResults'], {
|
||||
content: t(($) => $['newKnowledge.creators']),
|
||||
content: t(($) => $.knowledge),
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
@ -228,6 +240,7 @@ export function NewKnowledgeList({
|
||||
<KnowledgeSpaceCard
|
||||
key={knowledgeSpace.control_space_id}
|
||||
knowledgeSpace={knowledgeSpace}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@ -251,6 +264,16 @@ export function NewKnowledgeList({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TagManagementModal
|
||||
type="knowledge"
|
||||
show={showTagManagementModal}
|
||||
onClose={() => setShowTagManagementModal(false)}
|
||||
onTagsChange={() => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.knowledgeFs.spaces.get.key(),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{showExternalApiPanel && canConnect && (
|
||||
<ExternalAPIPanel
|
||||
canManageExternalKnowledgeApi={canConnect}
|
||||
|
||||
@ -139,6 +139,7 @@ export const TagFilter = ({
|
||||
type={type}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
canBindOrUnbindTags
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
@ -26,6 +26,7 @@ type TagSearchContentProps = {
|
||||
onOpenTagManagement?: () => void
|
||||
onClose?: () => void
|
||||
canBindOrUnbindTags?: boolean
|
||||
requiresTargetEditPermission?: boolean
|
||||
}
|
||||
|
||||
export const TagSearchContent = ({
|
||||
@ -35,10 +36,14 @@ export const TagSearchContent = ({
|
||||
onOpenTagManagement,
|
||||
onClose,
|
||||
canBindOrUnbindTags = false,
|
||||
requiresTargetEditPermission = false,
|
||||
}: TagSearchContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
|
||||
const canChangeBindings = requiresTargetEditPermission
|
||||
? canBindOrUnbindTags
|
||||
: canBindOrUnbindTags || canManageTags
|
||||
const filteredItems = useComboboxFilteredItems<TagComboboxItem>()
|
||||
const realItemCount = filteredItems.filter((tag) => !isCreateTagOption(tag)).length
|
||||
const placeholder = t(($) => $['tag.selectorPlaceholder'], { ns: 'common' }) || ''
|
||||
@ -93,11 +98,7 @@ export const TagSearchContent = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
key={tag.id}
|
||||
value={tag}
|
||||
disabled={!canBindOrUnbindTags && !canManageTags}
|
||||
>
|
||||
<ComboboxItem key={tag.id} value={tag} disabled={!canChangeBindings}>
|
||||
<ComboboxItemText title={tag.name}>{tag.name}</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
|
||||
@ -64,8 +64,11 @@ export type TagSelectorProps = TagSelectorRootProps &
|
||||
type: TagType
|
||||
value: Tag[]
|
||||
canBindOrUnbindTags?: boolean
|
||||
requiresTargetEditPermission?: boolean
|
||||
showProvidedTagNames?: boolean
|
||||
onOpenTagManagement?: () => void
|
||||
onTagsChange?: () => void
|
||||
onApplyTags?: (tagIds: string[]) => void
|
||||
}
|
||||
|
||||
export const TagSelector = ({
|
||||
@ -73,10 +76,13 @@ export const TagSelector = ({
|
||||
type,
|
||||
value,
|
||||
canBindOrUnbindTags,
|
||||
requiresTargetEditPermission = false,
|
||||
showProvidedTagNames = false,
|
||||
className,
|
||||
onClick,
|
||||
onOpenTagManagement = () => {},
|
||||
onTagsChange,
|
||||
onApplyTags,
|
||||
placement = 'bottom-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
@ -92,6 +98,9 @@ export const TagSelector = ({
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
|
||||
const canChangeBindings = requiresTargetEditPermission
|
||||
? !!canBindOrUnbindTags
|
||||
: !!canBindOrUnbindTags || canManageTags
|
||||
|
||||
const applyTagBindingsMutation = useApplyTagBindingsMutation()
|
||||
const { isPending: isCreatingTag, mutate: createTag } = useMutation(
|
||||
@ -113,10 +122,10 @@ export const TagSelector = ({
|
||||
|
||||
const tagNameById = new Map(tagList.map((tag) => [tag.id, tag.name]))
|
||||
return value.flatMap((tag) => {
|
||||
const tagName = tagNameById.get(tag.id)
|
||||
const tagName = tagNameById.get(tag.id) ?? (showProvidedTagNames ? tag.name : undefined)
|
||||
return tagName ? [tagName] : []
|
||||
})
|
||||
}, [tagList, value])
|
||||
}, [showProvidedTagNames, tagList, value])
|
||||
const emptyTriggerLabel = canBindOrUnbindTags
|
||||
? t(($) => $['tag.addTag'], { ns: 'common' })
|
||||
: t(($) => $['tag.noTag'], { ns: 'common' })
|
||||
@ -159,6 +168,11 @@ export const TagSelector = ({
|
||||
|
||||
if (!tagSelectionChanged) return
|
||||
|
||||
if (onApplyTags) {
|
||||
onApplyTags(draftTagIds)
|
||||
return
|
||||
}
|
||||
|
||||
const toastId = `tag-bindings-${type}-${targetId}`
|
||||
|
||||
applyTagBindingsMutation.mutate(
|
||||
@ -190,7 +204,16 @@ export const TagSelector = ({
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [applyTagBindingsMutation, draftTags, onTagsChange, selectedTagIds, t, targetId, type])
|
||||
}, [
|
||||
applyTagBindingsMutation,
|
||||
draftTags,
|
||||
onApplyTags,
|
||||
onTagsChange,
|
||||
selectedTagIds,
|
||||
t,
|
||||
targetId,
|
||||
type,
|
||||
])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
@ -259,7 +282,7 @@ export const TagSelector = ({
|
||||
isItemEqualToValue={isSameTag}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
disabled={!canManageTags && !canBindOrUnbindTags}
|
||||
disabled={!canChangeBindings}
|
||||
aria-label={triggerLabel}
|
||||
className={cn(
|
||||
'group/tag-area relative h-auto w-full cursor-pointer rounded-lg border-0 bg-transparent p-1 hover:bg-state-base-hover focus-visible:bg-transparent data-disabled:bg-transparent data-disabled:opacity-50 data-disabled:hover:bg-transparent data-popup-open:bg-state-base-hover data-popup-open:hover:bg-state-base-hover',
|
||||
@ -291,6 +314,7 @@ export const TagSelector = ({
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
canBindOrUnbindTags={canBindOrUnbindTags}
|
||||
requiresTargetEditPermission={requiresTargetEditPermission}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
/>
|
||||
|
||||
@ -504,6 +504,18 @@ describe('normalizeConsoleOpenAPIURL', () => {
|
||||
expect(searchParams.has('creator_ids[0]')).toBe(false)
|
||||
})
|
||||
|
||||
it('should serialize KnowledgeFS list query arrays as repeated params', () => {
|
||||
const url = normalizeConsoleOpenAPIURL(
|
||||
'https://example.com/console/api/knowledge-fs/spaces?tag_ids%5B0%5D=tag-1&creator_ids%5B0%5D=user-1',
|
||||
)
|
||||
const searchParams = new URL(url).searchParams
|
||||
|
||||
expect(searchParams.getAll('tag_ids')).toEqual(['tag-1'])
|
||||
expect(searchParams.getAll('creator_ids')).toEqual(['user-1'])
|
||||
expect(searchParams.has('tag_ids[0]')).toBe(false)
|
||||
expect(searchParams.has('creator_ids[0]')).toBe(false)
|
||||
})
|
||||
|
||||
it('should serialize snippet list query arrays as repeated params', () => {
|
||||
const url = normalizeConsoleOpenAPIURL(
|
||||
'https://example.com/console/api/workspaces/current/customized-snippets?tag_ids%5B0%5D=tag-1&creators%5B0%5D=user-1',
|
||||
|
||||
@ -15,7 +15,7 @@ const repeatedQueryArrayRules: readonly QueryArrayCompatibilityRule[] = [
|
||||
{ path: /\/datasets$/, fields: ['ids', 'tag_ids'] },
|
||||
{ path: /\/datasets\/[^/]+\/documents\/[^/]+\/segment\/[^/]+$/, fields: ['segment_id'] },
|
||||
{ path: /\/datasets\/[^/]+\/documents\/[^/]+\/segments$/, fields: ['segment_id', 'status'] },
|
||||
{ path: /\/knowledge-fs\/spaces$/, fields: ['creator_ids'] },
|
||||
{ path: /\/knowledge-fs\/spaces$/, fields: ['creator_ids', 'tag_ids'] },
|
||||
{ path: /\/trial-apps\/[^/]+\/datasets$/, fields: ['ids'] },
|
||||
{ path: /\/workspaces\/current\/customized-snippets$/, fields: ['tag_ids', 'creators'] },
|
||||
{ path: /\/workspaces\/current\/plugin\/[^/]+\/list$/, fields: ['tags'] },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user