fix(knowledge-fs): show installed datasource providers

This commit is contained in:
Stephen Zhou 2026-09-02 21:52:21 +08:00
parent 523844a774
commit 2ad99bf5cd
No known key found for this signature in database
10 changed files with 668 additions and 355 deletions

View File

@ -1184,7 +1184,7 @@ describe('CreateKnowledgePage', () => {
)
})
it('enables every atomic source type and distinguishes installed providers', async () => {
it('enables every atomic source type and only lists installed providers', async () => {
const user = userEvent.setup()
renderPage()
@ -1208,12 +1208,13 @@ describe('CreateKnowledgePage', () => {
expect(onlineDocuments).toBeEnabled()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDrive' })).toBeEnabled()
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
expect(screen.getByRole('radio', { name: 'Jina Reader' })).toBeEnabled()
expect(screen.getByRole('radio', { name: 'WaterCrawl' })).toBeEnabled()
expect(screen.queryByRole('radio', { name: 'Jina Reader' })).not.toBeInTheDocument()
expect(screen.queryByRole('radio', { name: 'WaterCrawl' })).not.toBeInTheDocument()
await user.click(onlineDocuments)
expect(onlineDocuments).toBeChecked()
expect(screen.getByRole('radio', { name: 'Notion' })).toBeChecked()
expect(screen.getByText('workflow.nodes.common.pluginNotInstalled')).toBeInTheDocument()
expect(screen.queryByRole('radio', { name: 'Notion' })).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('plugin.list.notFound')
expect(screen.queryByText('workflow.nodes.common.pluginNotInstalled')).not.toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.websiteCrawl' }))
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.moreProviders' })).toBeEnabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@ -1253,6 +1254,54 @@ describe('CreateKnowledgePage', () => {
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled()
})
it('prompts for provider installation when source setup has no installed integration', async () => {
const user = userEvent.setup()
datasourceQueryMock.plugins.data = []
datasourceQueryMock.auth.data = { result: [] }
renderPage()
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.connectSource' }))
expect(screen.getByRole('status')).toHaveTextContent('plugin.list.notFound')
expect(screen.queryByRole('radio', { name: 'Firecrawl' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.moreProviders' })).toBeEnabled()
})
it('clears a completed crawl when the installed provider changes during setup', async () => {
const user = userEvent.setup()
datasourceQueryMock.plugins.data = [firecrawlDatasourcePlugin, jinaDatasourcePlugin]
datasourceQueryMock.auth.data = { result: [firecrawlDatasourceAuth, jinaDatasourceAuth] }
const view = renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.connectSource' }))
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.rootUrlPlaceholder'),
'https://docs.dify.ai',
)
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder'),
'Dify docs',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
await user.click(await screen.findByRole('checkbox', { name: 'Getting started' }))
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }),
).toBeEnabled(),
)
datasourceQueryMock.plugins.data = [jinaDatasourcePlugin]
datasourceQueryMock.auth.data = { result: [jinaDatasourceAuth] }
view.rerender(<CreateKnowledgePage />)
expect(screen.getByRole('radio', { name: 'Jina Reader' })).toBeChecked()
expect(screen.queryByText('Getting started')).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.pagesAppearTitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled()
})
it('disables upload before creating a space when direct upload is unavailable', () => {
navigationMock.startMode = 'upload'
systemFeaturesStateMock.uploadEnabled = false

View File

@ -21,8 +21,6 @@ import { CrawlPreviewPageSelection } from '../sources/setup/crawl-selection'
import { WebsiteDatasourceParameterForm } from '../sources/setup/datasource-parameter-form'
import {
datasourceIncludeSubpages,
datasourceParameterDefaults,
datasourceParameterSchemas,
invalidDatasourceParameters,
missingRequiredDatasourceParameters,
websiteDatasourceParameterSchemas,
@ -31,14 +29,15 @@ import {
import {
SourceNameField,
SourceProviderCredentialRequiredCard,
SourceProviderEmptyState,
SourceProviderIcon,
SourceProviderNotInstalledCard,
SourceProviderRadioGroup,
SourceSyncPolicyField,
SourceTypeSelector,
} from '../sources/setup/fields'
import {
discoverSourceProviderOptions,
sourceDraftForProviderOption,
sourceProviderOptionForDraft,
} from '../sources/setup/provider-options'
@ -52,6 +51,13 @@ const CRAWL_POLL_INTERVAL_MS = 1500
type LocalCrawlState = 'error' | 'idle' | 'running' | 'stopped' | 'success'
type InitialSource = NonNullable<KnowledgeFsSpaceCreatePayload['initial_source']>
type CreateSourceSetupProps = {
disabled: boolean
draft: NewKnowledgeSourceDraft
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onInitialSourceChange: (source?: InitialSource) => void
onSourceTypeChange: (sourceType: NewKnowledgeSourceDraft['sourceType']) => void
}
function datasourceAuthForProvider(
authProviders: DataSourceAuth[],
@ -90,19 +96,106 @@ function websiteSourceUri(parameters: Record<string, boolean | number | string>,
return `datasource://${encodeURIComponent(fallback)}`
}
export function CreateSourceSetup({
export function CreateSourceSetup(props: CreateSourceSetupProps) {
const { disabled, draft, onDraftChange, onSourceTypeChange } = props
const { t } = useTranslation('dataset')
const datasourcePluginsQuery = useDataSourceList(true)
const datasourceAuthQuery = useGetDataSourceListAuth()
const providerOptions = useMemo(
() => discoverSourceProviderOptions(draft.sourceType, datasourcePluginsQuery.data ?? []),
[datasourcePluginsQuery.data, draft.sourceType],
)
const providerOption = sourceProviderOptionForDraft(providerOptions, draft)
const providerDraft = useMemo(
() => (providerOption ? sourceDraftForProviderOption(draft, providerOption) : draft),
[draft, providerOption],
)
const datasourceAuth = providerOption
? datasourceAuthForProvider(
datasourceAuthQuery.data?.result ?? [],
providerOption.plugin.plugin_id,
providerOption.plugin.provider,
)
: undefined
const credential = preferredCredential(datasourceAuth)
const sessionKey = [
draft.sourceType,
providerOption?.key ?? 'no-provider',
providerOption?.plugin.plugin_unique_identifier ?? 'no-plugin-version',
credential?.id ?? 'no-credential',
].join(':')
const selectProvider = (providerKey: string) => {
const nextProvider = providerOptions.find((option) => option.key === providerKey)
if (!nextProvider) return
onDraftChange(sourceDraftForProviderOption(providerDraft, nextProvider))
}
return (
<div className="mx-4 -mt-1 mb-3.75 flex flex-col gap-4">
<SourceTypeSelector
appearance="embedded"
disabled={disabled}
value={draft.sourceType}
onChange={onSourceTypeChange}
/>
<Fieldset disabled={disabled}>
<FieldsetLegend className="sr-only">
{t(($) => $['newKnowledge.providerLabel'])}
</FieldsetLegend>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.providerLabel'])}
</span>
<Button
type="button"
variant="ghost-accent"
size="small"
disabled={disabled}
className="gap-0.5 px-2.75"
onClick={() =>
globalThis.open(buildIntegrationPath('data-source'), '_blank', 'noopener,noreferrer')
}
>
{t(($) => $['newKnowledge.moreProviders'])}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Button>
</div>
{providerOptions.length > 0 ? (
<SourceProviderRadioGroup
value={providerOption?.key ?? ''}
disabled={disabled}
layout={draft.sourceType === 'websiteCrawl' ? 'grid-four' : 'grid-three'}
options={providerOptions.map((option) => ({
icon: (
<SourceProviderIcon
fallbackIcon={option.fallbackIcon}
icon={option.datasource.identity.icon ?? option.plugin.declaration.identity.icon}
/>
),
label: option.label,
value: option.key,
}))}
surface="default"
onChange={selectProvider}
/>
) : !datasourcePluginsQuery.isPending && !datasourcePluginsQuery.error ? (
<SourceProviderEmptyState className="min-h-16" />
) : null}
</Fieldset>
<CreateSourceSetupSession key={sessionKey} {...props} />
</div>
)
}
function CreateSourceSetupSession({
disabled,
draft,
onDraftChange,
onInitialSourceChange,
onSourceTypeChange,
}: {
disabled: boolean
draft: NewKnowledgeSourceDraft
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onInitialSourceChange: (source?: InitialSource) => void
onSourceTypeChange: (sourceType: NewKnowledgeSourceDraft['sourceType']) => void
}) {
}: CreateSourceSetupProps) {
const { t } = useTranslation('dataset')
const datasourcePluginsQuery = useDataSourceList(true)
const datasourceAuthQuery = useGetDataSourceListAuth()
@ -112,13 +205,16 @@ export function CreateSourceSetup({
const [selectedPageIds, setSelectedPageIds] = useState<Set<string>>(() => new Set())
const crawlAttemptRef = useRef(0)
const previewJobIdRef = useRef<string | undefined>(undefined)
const sourceType = draft.sourceType
const providerOptions = useMemo(
() => discoverSourceProviderOptions(sourceType, datasourcePluginsQuery.data ?? []),
[datasourcePluginsQuery.data, sourceType],
() => discoverSourceProviderOptions(draft.sourceType, datasourcePluginsQuery.data ?? []),
[datasourcePluginsQuery.data, draft.sourceType],
)
const providerOption = sourceProviderOptionForDraft(providerOptions, draft)
const installedProviderOption = providerOption?.installed ? providerOption : undefined
const providerDraft = useMemo(
() => (providerOption ? sourceDraftForProviderOption(draft, providerOption) : draft),
[draft, providerOption],
)
const installedProviderOption = providerOption
const datasourceAuth = installedProviderOption
? datasourceAuthForProvider(
datasourceAuthQuery.data?.result ?? [],
@ -135,26 +231,26 @@ export function CreateSourceSetup({
[draft.sourceType, installedProviderOption],
)
const parameters = useMemo(() => {
const current = withDatasourceParameterDefaults(parameterSchemas, draft.parameters)
const current = withDatasourceParameterDefaults(parameterSchemas, providerDraft.parameters)
if (
draft.sourceType === 'websiteCrawl' &&
draft.rootUrl &&
providerDraft.sourceType === 'websiteCrawl' &&
providerDraft.rootUrl &&
parameterSchemas.some((parameter) => parameter.name === 'url') &&
current.url === undefined
)
current.url = draft.rootUrl
current.url = providerDraft.rootUrl
return current
}, [draft, parameterSchemas])
}, [parameterSchemas, providerDraft])
const parametersValid =
!missingRequiredDatasourceParameters(parameterSchemas, parameters).length &&
!invalidDatasourceParameters(parameterSchemas, parameters).length
const selectionPages = previewPages
const previewReady = Boolean(
draft.sourceType === 'websiteCrawl' &&
providerDraft.sourceType === 'websiteCrawl' &&
credential &&
installedProviderOption &&
parametersValid &&
draft.sourceName.trim(),
providerDraft.sourceName.trim(),
)
const sourceUri = installedProviderOption
? websiteSourceUri(parameters, installedProviderOption.key)
@ -227,25 +323,6 @@ export function CreateSourceSetup({
const updateDraftWithoutReset = (nextDraft: NewKnowledgeSourceDraft) => {
onDraftChange(nextDraft)
}
const selectProvider = (providerKey: string) => {
const nextProvider = providerOptions.find((option) => option.key === providerKey)
if (!nextProvider) return
updateDraft({
...draft,
parameters: nextProvider.installed
? datasourceParameterDefaults(
draft.sourceType === 'websiteCrawl'
? websiteDatasourceParameterSchemas(nextProvider.datasource)
: datasourceParameterSchemas(nextProvider.datasource),
)
: {},
provider: nextProvider.label,
providerKey: nextProvider.key,
sourceName: '',
...(draft.sourceType === 'websiteCrawl' ? { rootUrl: '' } : {}),
})
}
useEffect(
() => () => {
crawlAttemptRef.current += 1
@ -256,13 +333,14 @@ export function CreateSourceSetup({
params: { job_id: jobId },
})
.catch(() => {})
onInitialSourceChange(undefined)
},
[],
[onInitialSourceChange],
)
const startPreview = async () => {
if (
draft.sourceType !== 'websiteCrawl' ||
providerDraft.sourceType !== 'websiteCrawl' ||
!previewReady ||
!credential ||
!installedProviderOption
@ -342,7 +420,7 @@ export function CreateSourceSetup({
}
useEffect(() => {
if (draft.sourceType !== 'websiteCrawl') {
if (providerDraft.sourceType !== 'websiteCrawl') {
if (!installedProviderOption || !credential) onInitialSourceChange(undefined)
return
}
@ -368,7 +446,7 @@ export function CreateSourceSetup({
credentialId: credential.id,
datasource: installedProviderOption.datasource.identity.name,
kind: 'website_crawl',
name: draft.sourceName.trim(),
name: providerDraft.sourceName.trim(),
pluginId: installedProviderOption.plugin.plugin_id,
provider: installedProviderOption.plugin.provider,
providerDisplayName: installedProviderOption.label,
@ -378,70 +456,25 @@ export function CreateSourceSetup({
source_url: page.sourceUrl,
...(page.title ? { title: page.title } : {}),
})),
...(draft.syncPolicy === 'custom' && draft.customIntervalSeconds
? { custom_interval_seconds: draft.customIntervalSeconds }
...(providerDraft.syncPolicy === 'custom' && providerDraft.customIntervalSeconds
? { custom_interval_seconds: providerDraft.customIntervalSeconds }
: {}),
sync_policy: draft.syncPolicy,
sync_policy: providerDraft.syncPolicy,
})
}, [
crawlState,
credential,
draft,
installedProviderOption,
onInitialSourceChange,
parameters,
providerDraft,
selectedPageIds,
selectionPages,
sourceUri,
])
return (
<div className="mx-4 -mt-1 mb-3.75 flex flex-col gap-4">
<SourceTypeSelector
appearance="embedded"
disabled={disabled}
value={sourceType}
onChange={(value) => {
onSourceTypeChange(value)
}}
/>
<Fieldset disabled={disabled}>
<FieldsetLegend className="sr-only">
{t(($) => $['newKnowledge.providerLabel'])}
</FieldsetLegend>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.providerLabel'])}
</span>
<Button
type="button"
variant="ghost-accent"
size="small"
disabled={disabled}
className="gap-0.5 px-2.75"
onClick={() =>
globalThis.open(buildIntegrationPath('data-source'), '_blank', 'noopener,noreferrer')
}
>
{t(($) => $['newKnowledge.moreProviders'])}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Button>
</div>
<SourceProviderRadioGroup
value={providerOption?.key ?? ''}
disabled={disabled}
layout={sourceType === 'websiteCrawl' ? 'grid-four' : 'grid-three'}
options={providerOptions.map((option) => ({
icon: <SourceProviderIcon fallbackIcon={option.fallbackIcon} />,
label: option.label,
value: option.key,
}))}
surface="default"
onChange={selectProvider}
/>
</Fieldset>
<>
{datasourcePluginsQuery.isPending || datasourceAuthQuery.isPending ? (
<div className="flex min-h-44 items-center justify-center">
<span aria-hidden className="i-ri-loader-4-line size-5 animate-spin text-text-tertiary" />
@ -460,22 +493,18 @@ export function CreateSourceSetup({
{t(($) => $['newKnowledge.retryProviderLoad'])}
</Button>
</div>
) : providerOption && !providerOption.installed ? (
<SourceProviderNotInstalledCard
icon={<SourceProviderIcon fallbackIcon={providerOption.fallbackIcon} />}
provider={providerOption.label}
onInstall={() =>
globalThis.open(
providerIntegrationPath(providerOption.packageId),
'_blank',
'noopener,noreferrer',
)
}
/>
) : installedProviderOption && !credential ? (
<SourceProviderCredentialRequiredCard
disabled={disabled}
icon={<SourceProviderIcon fallbackIcon={installedProviderOption.fallbackIcon} />}
icon={
<SourceProviderIcon
fallbackIcon={installedProviderOption.fallbackIcon}
icon={
installedProviderOption.datasource.identity.icon ??
installedProviderOption.plugin.declaration.identity.icon
}
/>
}
provider={installedProviderOption.label}
onConnect={() =>
globalThis.open(
@ -485,13 +514,13 @@ export function CreateSourceSetup({
)
}
/>
) : draft.sourceType === 'websiteCrawl' && installedProviderOption && credential ? (
) : providerDraft.sourceType === 'websiteCrawl' && installedProviderOption && credential ? (
<div className="space-y-4">
<WebsiteDatasourceParameterForm
additionalPrimaryField={
<SourceNameField
disabled={disabled}
draft={draft}
draft={providerDraft}
preventSubmitOnEnter
onDraftChange={updateDraft}
/>
@ -501,7 +530,7 @@ export function CreateSourceSetup({
schemas={parameterSchemas}
onChange={(nextParameters) =>
updateDraft({
...draft,
...providerDraft,
parameters: nextParameters,
rootUrl: typeof nextParameters.url === 'string' ? nextParameters.url : '',
})
@ -617,16 +646,16 @@ export function CreateSourceSetup({
<SourceSyncPolicyField
className="w-full sm:w-75.25"
disabled={disabled}
draft={draft}
draft={providerDraft}
onDraftChange={updateDraftWithoutReset}
size="medium"
/>
</div>
) : draft.sourceType !== 'websiteCrawl' && installedProviderOption && credential ? (
) : providerDraft.sourceType !== 'websiteCrawl' && installedProviderOption && credential ? (
<ConnectedSourceConfiguration
key={`${draft.sourceType}:${installedProviderOption.key}:${credential.id}`}
key={`${providerDraft.sourceType}:${installedProviderOption.key}:${credential.id}`}
disabled={disabled}
draft={draft}
draft={providerDraft}
previewBinding={{
credentialId: credential.id,
datasource: installedProviderOption.datasource.identity.name,
@ -639,6 +668,6 @@ export function CreateSourceSetup({
onInitialSourceChange={onInitialSourceChange}
/>
) : null}
</div>
</>
)
}

View File

@ -631,25 +631,151 @@ describe('ConnectedSourceWorkflow', () => {
expect(clientMock.createConnection).not.toHaveBeenCalled()
})
it('distinguishes an uninstalled provider from an installed provider without credentials', async () => {
const user = userEvent.setup()
it('falls back to an installed provider when the requested provider is not installed', async () => {
renderSetup({
...defaultDraft,
provider: 'Confluence',
})
expect(await screen.findByText('workflow.nodes.common.pluginNotInstalled')).toBeInTheDocument()
expect(screen.queryByRole('radio', { name: 'Confluence' })).not.toBeInTheDocument()
expect(await screen.findByRole('radio', { name: 'Notion' })).toBeChecked()
expect(
screen.queryByText('dataset.newKnowledge.providerNotConfigured:{"provider":"Notion"}'),
).not.toBeInTheDocument()
await screen.findByText('dataset.newKnowledge.providerNotConfigured:{"provider":"Notion"}'),
).toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.common.pluginNotInstalled')).not.toBeInTheDocument()
expect(clientMock.createConnection).not.toHaveBeenCalled()
})
await user.click(screen.getByRole('button', { name: 'plugin.installPlugin' }))
expect(openMock).toHaveBeenCalledWith(
'/integrations/data-source?package-ids=%5B%22langgenius%2Fconfluence_datasource%22%5D',
'_blank',
'noopener,noreferrer',
it('uses the fallback provider transport and clears stale provider parameters', async () => {
clientMock.listDatasourceAuth.mockResolvedValue({
result: [notionDatasourceAuth([notionCredential])],
})
clientMock.createConnection.mockResolvedValue(connectionResponse())
renderSetup({
...defaultDraft,
parameters: { folderId: 'stale-google-folder' },
provider: 'Google Docs',
sourceName: 'Old Google source',
})
await waitFor(() =>
expect(clientMock.createConnection).toHaveBeenCalledWith({
body: {
authKind: 'endpoint',
configuration: {
credentialId: notionCredential.id,
datasource: 'notion',
pluginId: 'langgenius/notion_datasource',
provider: 'notion',
providerKind: 'online-document',
},
credentials: {},
name: notionCredential.name,
providerId: notionProvider.id,
},
params: { control_space_id: 'space-1' },
}),
)
await waitFor(() =>
expect(clientMock.createSource).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
metadata: expect.objectContaining({
parameters: {},
providerKind: 'online-document',
providerName: 'Notion',
}),
name: 'Notion',
}),
}),
),
)
})
it('prompts for provider installation when no matching integration is installed', async () => {
clientMock.listDatasourcePlugins.mockResolvedValue([s3DatasourcePlugin])
renderSetup()
const prompt = await screen.findByText('plugin.list.notFound')
expect(prompt.closest('[role="status"]')).toBeInTheDocument()
expect(screen.queryByRole('radio', { name: 'Notion' })).not.toBeInTheDocument()
expect(screen.queryByText('dataset.newKnowledge.providerUnavailable')).not.toBeInTheDocument()
})
it('clears a failed connection attempt when a provider refresh selects another integration', async () => {
const outlineProvider: KnowledgeFsSourceProviderResponse = {
...notionProvider,
display_name: 'Outline',
id: 'outline-provider',
}
const outlineDatasourcePlugin: DataSourceItem = {
...notionDatasourcePlugin,
declaration: {
...notionDatasourcePlugin.declaration,
datasources: [
{
...notionDatasourcePlugin.declaration.datasources[0]!,
identity: {
...notionDatasourcePlugin.declaration.datasources[0]!.identity,
label: { en_US: 'Outline', zh_Hans: 'Outline' },
name: 'outline',
provider: 'outline',
},
},
],
identity: {
...notionDatasourcePlugin.declaration.identity,
label: { en_US: 'Outline', zh_Hans: 'Outline' },
name: 'outline',
},
},
plugin_id: 'langgenius/outline_datasource',
plugin_unique_identifier: 'langgenius/outline_datasource:1.0.0@local',
provider: 'outline',
}
const outlineDatasourceAuth: DataSourceAuth = {
...notionDatasourceAuth(),
label: { en_US: 'Outline', zh_Hans: 'Outline' },
name: 'outline',
plugin_id: outlineDatasourcePlugin.plugin_id,
plugin_unique_identifier: outlineDatasourcePlugin.plugin_unique_identifier,
provider: outlineDatasourcePlugin.provider,
}
clientMock.listDatasourceAuth.mockResolvedValue({
result: [notionDatasourceAuth([notionCredential])],
})
clientMock.createConnection.mockRejectedValue(new Error('provider unavailable'))
const { queryClient } = renderSetup()
expect(
await screen.findByText('dataset.newKnowledge.connectionFailed:{"provider":"Notion"}'),
).toHaveAttribute('role', 'alert')
clientMock.listProviders.mockResolvedValue({
data: [outlineProvider],
} satisfies KnowledgeFsSourceProviderListResponse)
await act(async () => {
queryClient.setQueryData(['pipeline', 'datasource'], [outlineDatasourcePlugin])
queryClient.setQueryData(['data-source-auth', 'list'], {
result: [outlineDatasourceAuth],
})
await queryClient.refetchQueries({ queryKey: ['source-providers'] })
})
expect(await screen.findByRole('radio', { name: 'Outline' })).toBeChecked()
expect(
await screen.findByText('dataset.newKnowledge.providerNotConfigured:{"provider":"Outline"}'),
).toBeInTheDocument()
expect(
screen.queryByText('dataset.newKnowledge.connectionFailed:{"provider":"Outline"}'),
).not.toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.connectProvider:{"provider":"Outline"}',
}),
).toBeEnabled()
})
it('starts the selected Notion import and completes setup without waiting for indexing', async () => {

View File

@ -727,8 +727,7 @@ describe('AddSourcePage', () => {
)
})
it('keeps the exact website provider selected while loading website dependencies', async () => {
const user = userEvent.setup()
it('falls back to an installed website provider when the requested provider is not installed', () => {
render(
<AddSourcePage
initialSourceDraft={{
@ -744,28 +743,52 @@ describe('AddSourcePage', () => {
/>,
)
expect(screen.getByRole('radio', { name: 'Jina Reader' })).toBeChecked()
expect(providerHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: true })
expect(connectionHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: true })
expect(screen.getByText('workflow.nodes.common.pluginNotInstalled')).toBeInTheDocument()
await user.click(
screen.getByRole('button', {
name: 'plugin.installPlugin',
}),
)
expect(openMock).toHaveBeenCalledWith(
'/integrations/data-source?package-ids=%5B%22langgenius%2Fjina_datasource%22%5D',
'_blank',
'noopener,noreferrer',
)
await user.click(screen.getByRole('radio', { name: 'Firecrawl' }))
expect(screen.queryByRole('radio', { name: 'Jina Reader' })).not.toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
expect(providerHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: true })
expect(connectionHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: true })
expect(screen.queryByText('workflow.nodes.common.pluginNotInstalled')).not.toBeInTheDocument()
})
it('keeps crawl fields hidden until the selected website provider is configured', async () => {
it('clears provider-specific draft fields when falling back to another website provider', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
render(
<AddSourcePage
initialSourceDraft={{
includeSubpages: false,
maxPages: 25,
parameters: {
crawl_sub_pages: false,
jinaOnly: 'stale-value',
url: 'https://jina.example.com',
},
provider: 'Jina Reader',
rootUrl: 'https://jina.example.com',
sourceName: 'Old Jina source',
sourceType: 'websiteCrawl',
syncPolicy: 'daily',
}}
knowledgeSpaceId="space-1"
/>,
)
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ })).toHaveValue('')
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ })).toHaveValue(
'',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlOptions' }))
expect(
screen.getByRole('checkbox', { name: 'dataset.newKnowledge.includeSubpages' }),
).toBeChecked()
expect(screen.getByRole('spinbutton', { name: 'dataset.newKnowledge.maxPages' })).toHaveValue(
100,
)
})
it('keeps crawl fields hidden until the selected website provider is configured', () => {
render(
<AddSourcePage
initialSourceDraft={{
@ -791,8 +814,8 @@ describe('AddSourcePage', () => {
).not.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' })).toBeEnabled()
await user.click(screen.getByRole('radio', { name: 'Jina Reader' }))
expect(screen.getByText('workflow.nodes.common.pluginNotInstalled')).toBeInTheDocument()
expect(screen.queryByRole('radio', { name: 'Jina Reader' })).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.common.pluginNotInstalled')).not.toBeInTheDocument()
expect(
screen.queryByRole('textbox', { name: 'dataset.newKnowledge.rootUrl' }),
).not.toBeInTheDocument()
@ -1245,6 +1268,19 @@ describe('AddSourcePage', () => {
expect(moreProvidersLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('prompts for provider installation when no datasource integration is installed', () => {
queryState.datasourcePlugins.data = []
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('status')).toHaveTextContent('plugin.list.notFound')
expect(screen.queryByRole('radio', { name: 'Firecrawl' })).not.toBeInTheDocument()
expect(
screen.getByRole('link', { name: 'dataset.newKnowledge.moreProviders' }),
).toHaveAttribute('href', '/integrations/data-source')
expect(screen.queryByText('dataset.newKnowledge.providerUnavailable')).not.toBeInTheDocument()
})
it('keeps the handed-off website draft when the provider connection becomes active', async () => {
const initialSourceDraft = {
includeSubpages: true,
@ -1578,7 +1614,7 @@ describe('AddSourcePage', () => {
})
expect(onlineDocuments).toBeEnabled()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDrive' })).toBeEnabled()
expect(screen.getByRole('radio', { name: 'Jina Reader' })).toBeEnabled()
expect(screen.queryByRole('radio', { name: 'Jina Reader' })).not.toBeInTheDocument()
expect(
screen.getByRole('group', { name: 'dataset.newKnowledge.providerLabel' }),
).toBeInTheDocument()
@ -1613,7 +1649,6 @@ describe('AddSourcePage', () => {
})
it.each([
['websiteCrawl', 'Jina Reader'],
['onlineDocuments', 'Confluence'],
['onlineDrive', 'OneDrive'],
] as const)('restores the %s provider from a shortcut URL', (initialSourceType, provider) => {
@ -1628,6 +1663,19 @@ describe('AddSourcePage', () => {
expect(screen.getByRole('radio', { name: provider })).toBeChecked()
})
it('does not expose an uninstalled website provider from a shortcut URL', () => {
render(
<AddSourcePage
initialSourceProvider="Jina Reader"
initialSourceType="websiteCrawl"
knowledgeSpaceId="space-1"
/>,
)
expect(screen.queryByRole('radio', { name: 'Jina Reader' })).not.toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
})
it('disables the final Add source action while its backend dependency is missing', async () => {
const user = userEvent.setup()
render(<AddSourcePage knowledgeSpaceId="space-1" />)

View File

@ -39,7 +39,6 @@ import {
} from '../connections/model'
import { DatasourceParameterForm } from '../setup/datasource-parameter-form'
import {
datasourceParameterDefaults,
datasourceParameterSchemas,
invalidDatasourceParameters,
missingRequiredDatasourceParameters,
@ -49,13 +48,13 @@ import {
SourceNameField,
SourceProviderCredentialRequiredCard,
SourceProviderIcon,
SourceProviderNotInstalledCard,
SourceProviderSelector,
SourceSyncPolicyField,
} from '../setup/fields'
import {
discoverSourceProviderOptions,
normalizeSourceProviderName,
sourceDraftForProviderOption,
sourceProviderOptionForDraft,
} from '../setup/provider-options'
import { NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH } from '../setup/source-draft'
@ -71,6 +70,13 @@ import {
type ConnectedSourceDraft =
| NewKnowledgeOnlineDocumentsSourceDraft
| NewKnowledgeOnlineDriveSourceDraft
type ConnectedSourceWorkflowProps = {
draft: ConnectedSourceDraft
knowledgeSpaceId: string
onCompleted: () => void
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onExit: () => void
}
type PageResource = {
ancestorKeys: string[]
depth: number
@ -136,7 +142,7 @@ function providerForDraft(
}
function datasourceProviderForOption(option?: SourceProviderOption) {
return option?.installed
return option
? {
datasource: option.datasource,
plugin: option.plugin,
@ -1493,19 +1499,62 @@ function AppliedResourceConfiguration({
)
}
export function ConnectedSourceWorkflow({
export function ConnectedSourceWorkflow(props: ConnectedSourceWorkflowProps) {
const { draft, onDraftChange } = props
const datasourcePluginsQuery = useDataSourceList(true)
const datasourceAuthQuery = useGetDataSourceListAuth()
const providerOptions = useMemo(
() => discoverSourceProviderOptions(draft.sourceType, datasourcePluginsQuery.data ?? []),
[datasourcePluginsQuery.data, draft.sourceType],
)
const providerOption = sourceProviderOptionForDraft(providerOptions, draft)
const providerDraft = useMemo(
() => (providerOption ? sourceDraftForProviderOption(draft, providerOption) : draft),
[draft, providerOption],
)
const datasourceProvider = datasourceProviderForOption(providerOption)
const datasourceAuth = datasourceAuthForProvider(
datasourceAuthQuery.data?.result ?? [],
datasourceProvider,
)
const credential = preferredCredential(datasourceAuth)
const sessionKey = [
draft.sourceType,
providerOption?.key ?? 'no-provider',
providerOption?.plugin.plugin_unique_identifier ?? 'no-plugin-version',
credential?.id ?? 'no-credential',
].join(':')
const selectProvider = (providerKey: string) => {
const nextProvider = providerOptions.find((option) => option.key === providerKey)
if (!nextProvider) return
onDraftChange(sourceDraftForProviderOption(providerDraft, nextProvider))
}
return (
<div className="flex flex-col gap-4">
<SourceProviderSelector
options={providerOptions}
providerKey={providerOption?.key ?? ''}
showEmptyState={
!datasourcePluginsQuery.error &&
!datasourcePluginsQuery.isPending &&
providerOptions.length === 0
}
onChange={selectProvider}
/>
<ConnectedSourceWorkflowSession key={sessionKey} {...props} />
</div>
)
}
function ConnectedSourceWorkflowSession({
draft,
knowledgeSpaceId,
onCompleted,
onDraftChange,
onExit,
}: {
draft: ConnectedSourceDraft
knowledgeSpaceId: string
onCompleted: () => void
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onExit: () => void
}) {
}: ConnectedSourceWorkflowProps) {
const { t } = useTranslation('dataset')
const queryClient = useQueryClient()
const providersQuery = useQuery(
@ -1538,21 +1587,25 @@ export function ConnectedSourceWorkflow({
[datasourcePluginsQuery.data, draft.sourceType],
)
const providerOption = sourceProviderOptionForDraft(providerOptions, draft)
const installedProviderOption = providerOption?.installed ? providerOption : undefined
const providerDraft = useMemo(
() => (providerOption ? sourceDraftForProviderOption(draft, providerOption) : draft),
[draft, providerOption],
)
const installedProviderOption = providerOption
const parameterSchemas = useMemo(
() =>
installedProviderOption ? datasourceParameterSchemas(installedProviderOption.datasource) : [],
[installedProviderOption],
)
const parameters = useMemo(
() => withDatasourceParameterDefaults(parameterSchemas, draft.parameters),
[draft.parameters, parameterSchemas],
() => withDatasourceParameterDefaults(parameterSchemas, providerDraft.parameters),
[parameterSchemas, providerDraft.parameters],
)
const parametersValid =
!missingRequiredDatasourceParameters(parameterSchemas, parameters).length &&
!invalidDatasourceParameters(parameterSchemas, parameters).length
const provider = providerForDraft(providersQuery.data ?? [], draft, providerOption)
const driveTransport = usesDriveTransport(draft)
const provider = providerForDraft(providersQuery.data ?? [], providerDraft, providerOption)
const driveTransport = usesDriveTransport(providerDraft)
const datasourceProvider = datasourceProviderForOption(installedProviderOption)
const datasourceAuth = datasourceAuthForProvider(
datasourceAuthQuery.data?.result ?? [],
@ -1659,7 +1712,7 @@ export function ConnectedSourceWorkflow({
providerKind: driveTransport ? 'online-drive' : 'online-document',
},
credentials: {},
name: credential.name || draft.provider,
name: credential.name || providerDraft.provider,
providerId: provider.id,
},
params: { control_space_id: knowledgeSpaceId },
@ -1687,9 +1740,9 @@ export function ConnectedSourceWorkflow({
datasourceIdentity,
datasourceProvider,
driveTransport,
draft.provider,
knowledgeSpaceId,
provider,
providerDraft.provider,
provisioningConnection,
refetchConnections,
rememberConnection,
@ -1716,40 +1769,8 @@ export function ConnectedSourceWorkflow({
providersQuery.isPending,
provisionConnection,
])
const selectProvider = (providerKey: string) => {
const nextProvider = providerOptions.find((option) => option.key === providerKey)
if (!nextProvider) return
setConnectionOverride(undefined)
setProvisionError(false)
if (draft.sourceType === 'onlineDocuments') {
onDraftChange({
...draft,
parameters: nextProvider.installed
? datasourceParameterDefaults(datasourceParameterSchemas(nextProvider.datasource))
: {},
provider: nextProvider.label,
providerKey: nextProvider.key,
sourceName: '',
})
return
}
onDraftChange({
...draft,
parameters: nextProvider.installed
? datasourceParameterDefaults(datasourceParameterSchemas(nextProvider.datasource))
: {},
provider: nextProvider.label,
providerKey: nextProvider.key,
sourceName: '',
})
}
return (
<div className="flex flex-col gap-4">
<SourceProviderSelector
options={providerOptions}
providerKey={providerOption?.key ?? ''}
onChange={selectProvider}
/>
<>
{providersQuery.isPending ||
datasourcePluginsQuery.isPending ||
datasourceAuthQuery.isPending ||
@ -1776,21 +1797,13 @@ export function ConnectedSourceWorkflow({
{t(($) => $['newKnowledge.retryProviderLoad'])}
</Button>
</div>
) : providerOption && !providerOption.installed ? (
<SourceProviderNotInstalledCard
icon={<SourceProviderIcon fallbackIcon={providerOption.fallbackIcon} />}
provider={providerOption.label}
onInstall={() =>
globalThis.open(
providerIntegrationPath(providerOption),
'_blank',
'noopener,noreferrer',
)
}
/>
) : !installedProviderOption || !provider ? (
) : providerOptions.length === 0 ? null : !installedProviderOption ? (
<div className="rounded-xl bg-background-section p-4 system-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.providerUnavailable'])}
</div>
) : !provider ? (
<div className="rounded-xl bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">{draft.provider}</p>
<p className="system-sm-semibold text-text-primary">{installedProviderOption.label}</p>
<p className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.providerUnavailable'])}
</p>
@ -1806,7 +1819,7 @@ export function ConnectedSourceWorkflow({
<AppliedResourceConfiguration
key={`${provider.id}:${connection.id}`}
connection={connection}
draft={draft}
draft={providerDraft}
knowledgeSpaceId={knowledgeSpaceId}
onCompleted={onCompleted}
onDraftChange={onDraftChange}
@ -1815,7 +1828,9 @@ export function ConnectedSourceWorkflow({
parametersValid={parametersValid}
parameterSchemas={parameterSchemas}
provider={provider}
providerRegion={draft.provider === 'Amazon S3' ? credentialRegion(credential) : undefined}
providerRegion={
providerDraft.provider === 'Amazon S3' ? credentialRegion(credential) : undefined
}
/>
) : connection?.status === 'provisioning' ? (
<div className="rounded-xl bg-background-section p-4">
@ -1837,7 +1852,7 @@ export function ConnectedSourceWorkflow({
</p>
<p className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.providerCredentialRequiredDescription'], {
provider: draft.provider,
provider: providerDraft.provider,
})}
</p>
<div className="mt-3 flex gap-2">
@ -1851,7 +1866,7 @@ export function ConnectedSourceWorkflow({
)
}
>
{t(($) => $['newKnowledge.connectProvider'], { provider: draft.provider })}
{t(($) => $['newKnowledge.connectProvider'], { provider: providerDraft.provider })}
</Button>
<Button loading={provisioningConnection} onClick={() => void provisionConnection(true)}>
{t(($) => $['newKnowledge.retryProviderLoad'])}
@ -1870,7 +1885,7 @@ export function ConnectedSourceWorkflow({
) : provisionError ? (
<div className="rounded-xl bg-background-section p-4">
<p role="alert" className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.connectionFailed'], { provider: draft.provider })}
{t(($) => $['newKnowledge.connectionFailed'], { provider: providerDraft.provider })}
</p>
<Button
className="mt-3"
@ -1904,7 +1919,7 @@ export function ConnectedSourceWorkflow({
/>
)}
{connection?.status !== 'active' && (
<ConnectedSourceSyncPolicyField draft={draft} onDraftChange={onDraftChange} />
<ConnectedSourceSyncPolicyField draft={providerDraft} onDraftChange={onDraftChange} />
)}
{!connection && (
<div className="mt-1 flex justify-between gap-2 border-t border-divider-subtle pt-4.75">
@ -1916,6 +1931,6 @@ export function ConnectedSourceWorkflow({
</Button>
</div>
)}
</div>
</>
)
}

View File

@ -36,19 +36,15 @@ import {
sourceConnectionStatusRank,
sourceProviderUsesManagedConfiguration,
} from '../connections/model'
import {
datasourceParameterDefaults,
websiteDatasourceParameterSchemas,
} from '../setup/datasource-parameter-model'
import {
SourceProviderIcon,
SourceProviderNotInstalledCard,
SourceProviderSelector,
SourceSyncPolicyField,
SourceTypeSelector,
} from '../setup/fields'
import {
discoverSourceProviderOptions,
sourceDraftForProviderOption,
sourceProviderOptionForDraft,
} from '../setup/provider-options'
import {
@ -571,10 +567,8 @@ function UnconfiguredProvider({
<SourceProviderIcon
fallbackIcon={providerOption.fallbackIcon}
icon={
providerOption.installed
? (providerOption.datasource.identity.icon ??
providerOption.plugin.declaration.identity.icon)
: undefined
providerOption.datasource.identity.icon ??
providerOption.plugin.declaration.identity.icon
}
/>
</span>
@ -847,8 +841,15 @@ function AddSourcePageContent({
[datasourcePluginsQuery.data],
)
const websiteProviderOption = sourceProviderOptionForDraft(websiteProviderOptions, sourceDraft)
const websiteProviderName = websiteProviderOption?.label ?? sourceDraft.provider
const datasourceProvider = websiteProviderOption?.installed ? websiteProviderOption : undefined
const providerDraft = useMemo(
() =>
sourceDraft.sourceType === 'websiteCrawl' && websiteProviderOption
? sourceDraftForProviderOption(sourceDraft, websiteProviderOption)
: sourceDraft,
[sourceDraft, websiteProviderOption],
)
const websiteProviderName = websiteProviderOption?.label ?? providerDraft.provider
const datasourceProvider = websiteProviderOption
const datasourceProviders = datasourceAuthQuery.data?.result ?? []
const datasourceCredential = findDatasourceCredential(datasourceProviders, datasourceProvider)
const difyManagedProvider = provider ? isDifyManagedProvider(provider) : false
@ -1041,35 +1042,26 @@ function AddSourcePageContent({
disabled={websiteSetupLocked}
value={sourceType}
onChange={(value) => {
sourceDraftsRef.current[sourceDraft.sourceType] = sourceDraft
sourceDraftsRef.current[providerDraft.sourceType] = providerDraft
updateSourceDraft(
sourceDraftsRef.current[value] ?? createNewKnowledgeSourceDraft(value),
)
}}
/>
{sourceDraft.sourceType === 'websiteCrawl' ? (
{providerDraft.sourceType === 'websiteCrawl' ? (
<>
<SourceProviderSelector
disabled={websiteSetupLocked}
layout="grid-four"
options={websiteProviderOptions}
providerKey={websiteProviderOption?.key ?? ''}
showEmptyState={!queryError && websiteProviderOptions.length === 0}
onChange={(providerKey) => {
const nextProvider = websiteProviderOptions.find(
(option) => option.key === providerKey,
)
if (!nextProvider) return
updateSourceDraft({
...sourceDraft,
parameters: nextProvider.installed
? datasourceParameterDefaults(
websiteDatasourceParameterSchemas(nextProvider.datasource),
)
: {},
provider: nextProvider.label,
providerKey: nextProvider.key,
rootUrl: '',
})
updateSourceDraft(sourceDraftForProviderOption(providerDraft, nextProvider))
}}
/>
{queryError ? (
@ -1091,19 +1083,7 @@ function AddSourcePageContent({
{t(($) => $['newKnowledge.retryProviderLoad'])}
</Button>
</div>
) : websiteProviderOption && !websiteProviderOption.installed ? (
<SourceProviderNotInstalledCard
icon={<SourceProviderIcon fallbackIcon={websiteProviderOption.fallbackIcon} />}
provider={websiteProviderOption.label}
onInstall={() =>
globalThis.open(
websiteProviderIntegrationPath(websiteProviderOption),
'_blank',
'noopener,noreferrer',
)
}
/>
) : !datasourceProvider || !provider ? (
) : websiteProviderOptions.length === 0 ? null : !datasourceProvider || !provider ? (
<div className="rounded-xl bg-background-section p-4 system-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.providerUnavailable'])}
</div>
@ -1118,7 +1098,7 @@ function AddSourcePageContent({
<WebsiteCrawlPreview
key={`${datasourceProvider.key}:${activeConnection.id}`}
connection={activeConnection}
initialDraft={sourceDraft}
initialDraft={providerDraft}
knowledgeSpaceId={knowledgeSpaceId}
onDraftFinished={clearStoredSourceDraft}
onInteractionLockChange={setWebsiteSetupLocked}
@ -1128,7 +1108,7 @@ function AddSourcePageContent({
<SourceSyncPolicyField
className="w-full sm:w-75.25"
disabled={websiteSetupLocked}
draft={sourceDraft}
draft={providerDraft}
size="medium"
onDraftChange={updateSourceDraft}
/>
@ -1174,7 +1154,7 @@ function AddSourcePageContent({
</>
) : (
<ConnectedSourceWorkflow
draft={sourceDraft}
draft={providerDraft}
knowledgeSpaceId={knowledgeSpaceId}
onCompleted={() => {
clearStoredSourceDraft()
@ -1189,7 +1169,7 @@ function AddSourcePageContent({
{sourceType === 'websiteCrawl' && !websiteReady && (
<SourceSyncPolicyField
className="w-full sm:w-75.25"
draft={sourceDraft}
draft={providerDraft}
size="medium"
onDraftChange={updateSourceDraft}
/>

View File

@ -1,4 +1,22 @@
import { sourceProviderPresentation } from '../provider-options'
import type { SourceProviderOption } from '../provider-options'
import { sourceDraftForProviderOption, sourceProviderPresentation } from '../provider-options'
function providerOption({ key, label }: { key: string; label: string }) {
return {
datasource: {
parameters: [
{
default: 'new-default',
label: { en_US: 'Workspace' },
name: 'workspace',
type: 'string',
},
],
},
key,
label,
} as SourceProviderOption
}
describe('sourceProviderPresentation', () => {
it.each([
@ -19,3 +37,51 @@ describe('sourceProviderPresentation', () => {
expect(sourceProviderPresentation('Notion Backup', 'onlineDocuments')).toBeUndefined()
})
})
describe('sourceDraftForProviderOption', () => {
it('resets provider-specific fields when a different provider has the same label', () => {
const draft = {
parameters: { oldWorkspace: 'stale-value' },
provider: 'Docs',
providerKey: 'onlineDocuments:old-provider',
sourceName: 'Existing source',
sourceType: 'onlineDocuments' as const,
syncPolicy: 'daily' as const,
}
expect(
sourceDraftForProviderOption(
draft,
providerOption({ key: 'onlineDocuments:new-provider', label: 'Docs' }),
),
).toEqual({
parameters: { workspace: 'new-default' },
provider: 'Docs',
providerKey: 'onlineDocuments:new-provider',
sourceName: '',
sourceType: 'onlineDocuments',
syncPolicy: 'daily',
})
})
it('preserves provider fields when the stable provider key matches after a label change', () => {
const draft = {
parameters: { workspace: 'saved-workspace' },
provider: 'Old Docs Name',
providerKey: 'onlineDocuments:provider',
sourceName: 'Existing source',
sourceType: 'onlineDocuments' as const,
syncPolicy: 'daily' as const,
}
expect(
sourceDraftForProviderOption(
draft,
providerOption({ key: 'onlineDocuments:provider', label: 'New Docs Name' }),
),
).toEqual({
...draft,
provider: 'New Docs Name',
})
})
})

View File

@ -128,6 +128,26 @@ export function SourceProviderRadioGroup<T extends string>({
)
}
export function SourceProviderEmptyState({ className }: { className?: string }) {
const { t } = useTranslation('plugin')
return (
<div
role="status"
aria-live="polite"
className={cn(
'flex min-h-20 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-divider-regular bg-background-section px-4 py-3 text-center',
className,
)}
>
<span className="flex size-8 items-center justify-center rounded-lg bg-background-default">
<span aria-hidden className="i-ri-plug-line size-4 text-text-tertiary" />
</span>
<span className="system-xs-regular text-text-tertiary">{t(($) => $['list.notFound'])}</span>
</div>
)
}
type SourceProviderIconValue =
| InstalledSourceProviderOption['datasource']['identity']['icon']
| InstalledSourceProviderOption['plugin']['declaration']['identity']['icon']
@ -174,6 +194,7 @@ export function SourceProviderSelector({
layout = 'grid-three',
options,
providerKey,
showEmptyState = false,
onChange,
}: {
appearance?: 'embedded' | 'page'
@ -181,6 +202,7 @@ export function SourceProviderSelector({
layout?: 'grid-four' | 'grid-three'
options: SourceProviderOption[]
providerKey: string
showEmptyState?: boolean
onChange: (providerKey: string) => void
}) {
const { t } = useTranslation('dataset')
@ -213,28 +235,28 @@ export function SourceProviderSelector({
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Link>
</div>
<SourceProviderRadioGroup
value={providerKey}
disabled={disabled}
layout={layout}
options={options.map((option) => ({
icon: (
<SourceProviderIcon
fallbackIcon={option.fallbackIcon}
icon={
option.installed
? (option.datasource.identity.icon ?? option.plugin.declaration.identity.icon)
: undefined
}
/>
),
label: option.label,
value: option.key,
}))}
size="medium"
surface="default"
onChange={onChange}
/>
{options.length > 0 ? (
<SourceProviderRadioGroup
value={providerKey}
disabled={disabled}
layout={layout}
options={options.map((option) => ({
icon: (
<SourceProviderIcon
fallbackIcon={option.fallbackIcon}
icon={option.datasource.identity.icon ?? option.plugin.declaration.identity.icon}
/>
),
label: option.label,
value: option.key,
}))}
size="medium"
surface="default"
onChange={onChange}
/>
) : showEmptyState ? (
<SourceProviderEmptyState />
) : null}
</Fieldset>
)
}
@ -384,31 +406,3 @@ export function SourceProviderCredentialRequiredCard({
/>
)
}
export function SourceProviderNotInstalledCard({
icon,
provider,
onInstall,
}: {
icon: ReactNode
provider: string
onInstall: () => void
}) {
const { t: tPlugin } = useTranslation('plugin')
const { t: tWorkflow } = useTranslation('workflow')
return (
<section className="flex min-h-44 flex-col items-start gap-2.5 rounded-xl bg-background-section p-4">
<span className="flex size-9 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle bg-background-default">
{icon}
</span>
<h3 className="system-sm-semibold text-text-primary">{provider}</h3>
<p className="system-xs-regular text-text-tertiary">
{tWorkflow(($) => $['nodes.common.pluginNotInstalled'])}
</p>
<Button type="button" variant="primary" className="mt-auto" onClick={onInstall}>
{tPlugin(($) => $.installPlugin)}
</Button>
</section>
)
}

View File

@ -1,5 +1,10 @@
import type { NewKnowledgeSourceDraft, NewKnowledgeSourceType } from './source-draft'
import type { DataSourceItem } from '@/app/components/workflow/block-selector/types'
import {
datasourceParameterDefaults,
datasourceParameterSchemas,
websiteDatasourceParameterSchemas,
} from './datasource-parameter-model'
type Datasource = DataSourceItem['declaration']['datasources'][number]
@ -15,7 +20,6 @@ type RecommendedProvider = {
export type InstalledSourceProviderOption = {
datasource: Datasource
fallbackIcon: string
installed: true
key: string
label: string
packageId: string
@ -24,17 +28,7 @@ export type InstalledSourceProviderOption = {
sourceType: NewKnowledgeSourceType
}
export type UninstalledSourceProviderOption = {
fallbackIcon: string
installed: false
key: string
label: string
packageId: string
providerType: DataSourceItem['declaration']['provider_type']
sourceType: NewKnowledgeSourceType
}
export type SourceProviderOption = InstalledSourceProviderOption | UninstalledSourceProviderOption
export type SourceProviderOption = InstalledSourceProviderOption
const recommendedProviders: RecommendedProvider[] = [
{
@ -185,7 +179,6 @@ function installedRecommendedProvider(
return {
datasource,
fallbackIcon: definition.fallbackIcon,
installed: true,
key: providerKey(definition.sourceType, plugin, datasource),
label: definition.label,
packageId: definition.packageId,
@ -226,23 +219,11 @@ export function discoverSourceProviderOptions(
const definitions = recommendedProviders.filter(
(definition) => definition.sourceType === sourceType,
)
const recommended: SourceProviderOption[] = definitions.map((definition) => {
const recommended = definitions.flatMap((definition) => {
const installed = installedRecommendedProvider(definition, datasourcePlugins)
return (
installed ?? {
fallbackIcon: definition.fallbackIcon,
installed: false as const,
key: `${sourceType}:marketplace:${definition.packageId}`,
label: definition.label,
packageId: definition.packageId,
providerType: definition.providerType,
sourceType,
}
)
return installed ? [installed] : []
})
const consumedKeys = new Set(
recommended.flatMap((option) => (option.installed ? [option.key] : [])),
)
const consumedKeys = new Set(recommended.map((option) => option.key))
const labels = new Set(recommended.map((option) => option.label))
const discovered: InstalledSourceProviderOption[] = []
@ -261,7 +242,6 @@ export function discoverSourceProviderOptions(
discovered.push({
datasource,
fallbackIcon: fallbackIcon(sourceType),
installed: true,
key,
label,
packageId: plugin.plugin_id,
@ -288,3 +268,32 @@ export function sourceProviderOptionForDraft(
options[0]
)
}
export function sourceDraftForProviderOption<T extends NewKnowledgeSourceDraft>(
draft: T,
option: SourceProviderOption,
): T {
const providerMatches =
draft.providerKey !== undefined
? draft.providerKey === option.key
: normalizeSourceProviderName(draft.provider) === normalizeSourceProviderName(option.label)
if (providerMatches)
return draft.provider === option.label && draft.providerKey === option.key
? draft
: ({ ...draft, provider: option.label, providerKey: option.key } as T)
const schemas =
draft.sourceType === 'websiteCrawl'
? websiteDatasourceParameterSchemas(option.datasource)
: datasourceParameterSchemas(option.datasource)
return {
...draft,
parameters: datasourceParameterDefaults(schemas),
provider: option.label,
providerKey: option.key,
sourceName: '',
...(draft.sourceType === 'websiteCrawl'
? { includeSubpages: true, maxPages: 100, rootUrl: '' }
: {}),
} as T
}

View File

@ -224,7 +224,7 @@ function ConnectedSourceEditDialogContent({
const providerOption = providerOptions.find(
(option) => normalizeSourceProviderName(option.label) === normalizedProviderName,
)
const installedProviderOption = providerOption?.installed ? providerOption : undefined
const installedProviderOption = providerOption
const connections =
connectionsData?.pages.flatMap((page) => sourceConnectionListFromApi(page).items) ?? []
const connection = connections.find((item) => item.id === source.connectionId)
@ -516,13 +516,10 @@ function WebsiteSourceEditDialogContent({
const providerLoading = usesProviderDeclaration && datasourcePluginsQuery.isPending
const providerLoadFailed = usesProviderDeclaration && datasourcePluginsQuery.isError
const providerConfigurationReady =
!usesProviderDeclaration ||
(!providerLoading && !providerLoadFailed && providerOption?.installed === true)
!usesProviderDeclaration || (!providerLoading && !providerLoadFailed && Boolean(providerOption))
const parameterSchemas = useMemo(() => {
if (!usesProviderDeclaration) return websiteDatasourceParameterSchemas()
return providerOption?.installed
? websiteDatasourceParameterSchemas(providerOption.datasource)
: []
return providerOption ? websiteDatasourceParameterSchemas(providerOption.datasource) : []
}, [providerOption, usesProviderDeclaration])
const displayedParameters = useMemo(
() => sourceParametersForSchemas(initialSource, nextParameters, parameterSchemas),
@ -577,7 +574,7 @@ function WebsiteSourceEditDialogContent({
}
const startPreview = async () => {
if (previewing || !parametersValid || !providerOption?.installed || !previewBindingReady) return
if (previewing || !parametersValid || !providerOption || !previewBindingReady) return
const attempt = previewAttemptRef.current + 1
previewAttemptRef.current = attempt
setPreviewing(true)
@ -735,7 +732,7 @@ function WebsiteSourceEditDialogContent({
pending ||
previewing ||
!parametersValid ||
!providerOption?.installed ||
!providerOption ||
!previewBindingReady
}
loading={previewing}