Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import React from 'react';
import { render, screen, act } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import type { AIConnector } from '@kbn/elastic-assistant';
import { Subject } from 'rxjs';

jest.mock('@kbn/inference-connectors', () => ({
useLoadConnectors: jest.fn(),
Expand Down Expand Up @@ -118,16 +119,22 @@ const setup = ({
defaultConnectorOnly = false,
initialConnectorId,
}: RenderOptions = {}) => {
const featureSettingsSaved$ = new Subject<void>();
mockUseKibana.mockReturnValue({
services: {
http: {} as any,
settings: {} as any,
plugins: {
searchInferenceEndpoints: { featureSettingsSaved$ },
},
},
} as any);

const refetch = jest.fn();
mockUseLoadConnectors.mockReturnValue({
data: connectors,
isLoading,
refetch,
} as any);

// Default: pick defaultConnectorId if it's in the list, otherwise fall back to the first connector.
Expand All @@ -154,8 +161,17 @@ const setup = ({
return {
...utils,
selectConnector,
// Helper to re-render with a new connector selection (simulates admin changing a setting).
refetch,
featureSettingsSaved$,
// Helper to re-render with updated context (simulates admin changing a setting).
updateContext: (next: Partial<RenderOptions>) => {
if ('connectors' in next) {
mockUseLoadConnectors.mockReturnValue({
data: next.connectors ?? connectors,
isLoading: next.isLoading ?? isLoading,
refetch,
} as any);
}
mockUseConnectorSelection.mockReturnValue({
selectedConnector: next.selectedConnector ?? selectedConnector,
selectConnector,
Expand Down Expand Up @@ -250,6 +266,20 @@ describe('ConnectorSelector sync effect', () => {
expect(selectConnector).not.toHaveBeenCalled();
});

it('calls refetch when featureSettingsSaved$ emits', () => {
const connectors = [mkConnector('A')];
const { refetch, featureSettingsSaved$ } = setup({
connectors,
selectedConnector: 'A',
});

act(() => {
featureSettingsSaved$.next();
});

expect(refetch).toHaveBeenCalledTimes(1);
});

it('does not act while connectors are still loading', () => {
const { selectConnector } = setup({
connectors: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ type ConnectorOptionData = EuiSelectableOption<{}>;

export const ConnectorSelector: React.FC<{}> = () => {
const {
services: { http, settings },
services: {
http,
settings,
plugins: { searchInferenceEndpoints },
},
} = useKibana();
const {
selectConnector: onSelectConnector,
Expand All @@ -184,12 +188,23 @@ export const ConnectorSelector: React.FC<{}> = () => {
} = useConnectorSelection();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);

const { data: aiConnectors, isLoading } = useLoadConnectors({
const {
data: aiConnectors,
isLoading,
refetch,
} = useLoadConnectors({
http,
featureId: 'agent_builder',
settings,
});

// Refetch immediately when the Feature Settings page saves inference settings so the
// model list updates without requiring a page reload.
useEffect(() => {
const subscription = searchInferenceEndpoints.featureSettingsSaved$.subscribe(refetch);
return () => subscription.unsubscribe();
}, [searchInferenceEndpoints.featureSettingsSaved$, refetch]);

const connectors = useMemo(() => aiConnectors ?? [], [aiConnectors]);

const { recommendedConnectors, otherConnectors, customConnectors } = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-action
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import type { EvalsPublicStart } from '@kbn/evals-plugin/public';
import type { SearchInferenceEndpointsPluginStart } from '@kbn/search-inference-endpoints/public';

export type {
AgentBuilderPluginSetup,
Expand Down Expand Up @@ -68,4 +69,5 @@ export interface AgentBuilderStartDependencies {
security?: SecurityPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
searchInferenceEndpoints: SearchInferenceEndpointsPluginStart;
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const mockUseKibana = useKibana as jest.Mock;

const mockNavigateToUrl = jest.fn();
const mockBasePath = { prepend: jest.fn((path: string) => path) };
let mockNotifyFeatureSettingsSaved: jest.Mock;

const childFeature: InferenceFeatureConfig = {
featureId: 'child_1',
Expand Down Expand Up @@ -122,10 +123,12 @@ describe('ModelSettings', () => {
data: [{ connectorId: 'test-connector', name: 'Test', isPreconfigured: true }],
isLoading: false,
});
mockNotifyFeatureSettingsSaved = jest.fn();
mockUseKibana.mockReturnValue({
services: {
application: { navigateToUrl: mockNavigateToUrl },
http: { basePath: mockBasePath },
notifyFeatureSettingsSaved: mockNotifyFeatureSettingsSaved,
},
});
});
Expand Down Expand Up @@ -308,6 +311,49 @@ describe('ModelSettings', () => {
});
});

it('calls notifyFeatureSettingsSaved when all saves succeed', async () => {
mockUseModelSettingsForm.mockReturnValue({
...defaultFormState,
isDirty: true,
save: jest.fn().mockResolvedValue(undefined),
});

render(
<Wrapper>
<ModelSettings />
</Wrapper>
);

fireEvent.click(screen.getByTestId('save-settings-button'));

await waitFor(() => {
expect(mockNotifyFeatureSettingsSaved).toHaveBeenCalledTimes(1);
});
});

it('does not call notifyFeatureSettingsSaved when a save fails', async () => {
mockUseModelSettingsForm.mockReturnValue({
...defaultFormState,
isDirty: true,
save: jest.fn().mockRejectedValue(new Error('save failed')),
});

render(
<Wrapper>
<ModelSettings />
</Wrapper>
);

fireEvent.click(screen.getByTestId('save-settings-button'));

// Allow the async save to settle.
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});

expect(mockNotifyFeatureSettingsSaved).not.toHaveBeenCalled();
});

it('renders empty state when no sections are registered', () => {
mockUseModelSettingsForm.mockReturnValue({ ...defaultFormState, sections: [] });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const ModelSettings: React.FC = () => {
const defaultModelValidation = useDefaultModelValidation(defaultModelState);
const { data: connectors, isLoading: connectorsLoading } = useConnectors();
const {
services: { application, http },
services: { application, http, notifyFeatureSettingsSaved },
} = useKibana();
const usageTracker = useUsageTracker();
const deprecatedEndpointsMap: Map<string, EndpointDeprecationInfo> = useMemo(() => {
Expand Down Expand Up @@ -165,13 +165,15 @@ export const ModelSettings: React.FC = () => {
const allSavesSucceeded = results.every((result) => result.status === 'fulfilled');
if (allSavesSucceeded) {
usageTracker.count(EventType.FEATURE_SETTINGS_SAVED);
notifyFeatureSettingsSaved();
}
}, [
isFeatureDirty,
saveFeatures,
isDefaultModelDirty,
saveDefaultModel,
defaultModelValidation.isValid,
notifyFeatureSettingsSaved,
usageTracker,
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import type { Subscription } from 'rxjs';
import { Subject, type Subscription } from 'rxjs';

import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
Expand Down Expand Up @@ -33,6 +33,7 @@ export class SearchInferenceEndpointsPlugin
private registerModelSettings?: ManagementApp;
private registerElasticInferenceService?: ManagementApp;
private licenseSubscription?: Subscription;
private readonly featureSettingsSaved$ = new Subject<void>();
constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.get<SearchInferenceEndpointsConfigType>();
}
Expand Down Expand Up @@ -65,6 +66,7 @@ export class SearchInferenceEndpointsPlugin
},
});

const { featureSettingsSaved$ } = this;
this.registerModelSettings = plugins.management.sections.section.modelManagement.registerApp({
id: MODEL_SETTINGS_APP_ID,
title: i18n.translate('xpack.searchInferenceEndpoints.modelSettingsTitle', {
Expand All @@ -78,6 +80,7 @@ export class SearchInferenceEndpointsPlugin
...depsStart,
history,
setBreadcrumbs,
notifyFeatureSettingsSaved: () => featureSettingsSaved$.next(),
};

return renderSettingsMgmtApp(coreStart, startDeps, element);
Expand Down Expand Up @@ -135,10 +138,13 @@ export class SearchInferenceEndpointsPlugin
}
});

return {};
return {
featureSettingsSaved$: this.featureSettingsSaved$.asObservable(),
};
}

public stop() {
this.licenseSubscription?.unsubscribe();
this.featureSettingsSaved$.complete();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,23 @@ import type {
UsageCollectionStart,
} from '@kbn/usage-collection-plugin/public';
import type { ServiceProviderKeys } from '@kbn/inference-endpoint-ui-common';
import type { Observable } from 'rxjs';
import type { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types';
import type { EisModelStatus } from '../common/types';

export * from '../common/types';

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SearchInferenceEndpointsPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SearchInferenceEndpointsPluginStart {}

export interface SearchInferenceEndpointsPluginStart {
/**
* Emits once each time the Feature Settings page saves successfully.
* Subscribe to trigger a refetch of any connector/model list that may be
* affected by feature-specific SO assignment changes.
*/
featureSettingsSaved$: Observable<void>;
}

export interface AppPluginStartDependencies {
history: AppMountParameters['history'];
Expand All @@ -43,6 +51,8 @@ export interface AppPluginStartDependencies {
cloud?: CloudStart;
cloudConnect?: CloudConnectedPluginStart;
usageCollection?: UsageCollectionStart;
/** Callback provided by the plugin to signal a successful Feature Settings save. */
notifyFeatureSettingsSaved: () => void;
}

export interface AppPluginSetupDependencies {
Expand Down
Loading