Skip to content
Merged
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ import type { Services } from '../services';
import { SampleDataCardProvider } from '../services';
import { getMockServices } from '../mocks';

// Mock the polling functions to resolve immediately in tests
jest.mock('../hooks/poll_sample_data_status', () => ({
pollForInstallation: jest.fn(async () => Promise.resolve()),
pollForRemoval: jest.fn(async () => Promise.resolve()),
}));

describe('install footer', () => {
beforeEach(() => {
jest.resetAllMocks();
Expand Down Expand Up @@ -64,10 +70,11 @@ describe('install footer', () => {
});

test('should not invoke onInstall when install button is clicked and an error is thrown', async () => {
const installSampleDataSet = jest.fn(async () => {
throw new Error('error');
});
const component = mount(<InstallFooter {...props} />, {
installSampleDataSet: () => {
throw new Error('error');
},
installSampleDataSet,
});

await act(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import type { Services } from '../services';
import { SampleDataCardProvider } from '../services';
import { getMockServices } from '../mocks';

describe('install footer', () => {
// Mock the polling functions to resolve immediately in tests
jest.mock('../hooks/poll_sample_data_status', () => ({
pollForInstallation: jest.fn(async () => Promise.resolve()),
pollForRemoval: jest.fn(async () => Promise.resolve()),
}));

describe('remove footer', () => {
beforeEach(() => {
jest.resetAllMocks();
});
Expand Down Expand Up @@ -66,10 +72,11 @@ describe('install footer', () => {
});

test('should not invoke onRemove when remove button is clicked and an error is thrown', async () => {
const removeSampleDataSet = jest.fn(async () => {
throw new Error('error');
});
const component = mount(<RemoveFooter {...props} />, {
removeSampleDataSet: () => {
throw new Error('error');
},
removeSampleDataSet,
});

await act(async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import pRetry from 'p-retry';
import type { SampleDataSet } from '@kbn/home-sample-data-types';

/**
* Options for polling sample data status after installation or removal.
*/
export interface PollOptions {
/**
* Maximum number of retry attempts (default: 10)
*/
maxAttempts?: number;

/**
* Initial delay before first check in milliseconds
*/
initialDelayMs?: number;

/**
* Minimum time between poll attempts in milliseconds
*/
minTimeout?: number;

/**
* Factor for exponential backoff (default: 1.5)
*/
factor?: number;

/**
* Callback for logging failed attempts
*/
onFailedAttempt?: (error: Error, attemptNumber: number) => void;
}

/**
* Poll the sample data list endpoint until the dataset reaches the desired status.
*/
async function pollSampleDataStatus(
id: string,
pollFor: 'installation' | 'removal',
fetchSampleDataSets: () => Promise<SampleDataSet[]>,
options: PollOptions = {}
): Promise<void> {
const {
maxAttempts = 10,
initialDelayMs = pollFor === 'installation' ? 1000 : 500,
minTimeout = pollFor === 'installation' ? 1000 : 500,
factor = 1.5,
onFailedAttempt,
} = options;

await new Promise((resolve) => setTimeout(resolve, initialDelayMs));

await pRetry(
async () => {
const dataSets = await fetchSampleDataSets();
const dataset = dataSets.find((ds) => ds.id === id);

// Check if target status is reached
const isComplete =
pollFor === 'installation'
? dataset?.status === 'installed'
: !dataset || dataset?.status === 'not_installed';

if (isComplete) {
return;
}

// Build error message based on operation
const statusMessage =
pollFor === 'installation'
? `not yet installed (status: ${dataset?.status})`
: `still installed (status: ${dataset?.status})`;

throw new Error(`Sample data set ${id} ${statusMessage}`);
},
{
retries: maxAttempts,
minTimeout,
factor,
onFailedAttempt: (error) => {
if (onFailedAttempt) {
onFailedAttempt(error, error.attemptNumber);
}
// eslint-disable-next-line no-console
console.debug(
`Poll attempt ${error.attemptNumber}/${maxAttempts} for ${id}:`,
error.message
);
},
}
);
}

/**
* Poll the sample data list endpoint until the dataset shows as installed.
*/
export async function pollForInstallation(
id: string,
fetchSampleDataSets: () => Promise<SampleDataSet[]>,
options: PollOptions = {}
): Promise<void> {
return pollSampleDataStatus(id, 'installation', fetchSampleDataSets, options);
}

/**
* Poll the sample data list endpoint until the dataset shows as uninstalled.
*/
export async function pollForRemoval(
id: string,
fetchSampleDataSets: () => Promise<SampleDataSet[]>,
options: PollOptions = {}
): Promise<void> {
return pollSampleDataStatus(id, 'removal', fetchSampleDataSets, options);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n';

import type { SampleDataSet } from '@kbn/home-sample-data-types';
import { useServices } from '../services';
import { pollForInstallation } from './poll_sample_data_status';

/**
* Parameters for the `useInstall` React hook.
Expand All @@ -23,22 +24,36 @@ export type Params = Pick<SampleDataSet, 'id' | 'defaultIndex' | 'name'> & {

/**
* A React hook that allows a component to install a sample data set, handling success and
* failure in the Kibana UI. It also provides a boolean that indicates if the data set is
* failure in the Kibana UI. It also provides a boolean that indicates if the data set is
* in the process of being installed.
*
* After installation, this hook polls the status endpoint until the data is confirmed
* as installed
*/
export const useInstall = ({
id,
defaultIndex,
name,
onInstall,
}: Params): [() => void, boolean] => {
const { installSampleDataSet, notifyError, notifySuccess } = useServices();
const { installSampleDataSet, fetchSampleDataSets, notifyError, notifySuccess } = useServices();
const [isInstalling, setIsInstalling] = React.useState(false);

const install = useCallback(async () => {
try {
setIsInstalling(true);

// Call the install API (bulk insert without refresh)
await installSampleDataSet(id, defaultIndex);

// Poll until ES index is refreshed and status shows installed
await pollForInstallation(id, fetchSampleDataSets, {
maxAttempts: 20,
initialDelayMs: 1000,
minTimeout: 1000,
factor: 1.5,
});

setIsInstalling(false);

notifySuccess({
Expand All @@ -48,6 +63,8 @@ export const useInstall = ({
}),
['data-test-subj']: 'sampleDataSetInstallToast',
});

// Call onInstall callback when installation is confirmed
onInstall(id);
} catch (e) {
setIsInstalling(false);
Expand All @@ -59,7 +76,16 @@ export const useInstall = ({
text: `${e.message}`,
});
}
}, [installSampleDataSet, notifyError, notifySuccess, id, defaultIndex, name, onInstall]);
}, [
installSampleDataSet,
fetchSampleDataSets,
notifyError,
notifySuccess,
id,
defaultIndex,
name,
onInstall,
]);

return [install, isInstalling];
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n';

import type { SampleDataSet } from '@kbn/home-sample-data-types';
import { useServices } from '../services';
import { pollForRemoval } from './poll_sample_data_status';

/**
* Parameters for the `useRemove` React hook.
Expand All @@ -23,17 +24,30 @@ export type Params = Pick<SampleDataSet, 'id' | 'defaultIndex' | 'name'> & {

/**
* A React hook that allows a component to remove a sample data set, handling success and
* failure in the Kibana UI. It also provides a boolean that indicates if the data set is
* failure in the Kibana UI. It also provides a boolean that indicates if the data set is
* in the process of being removed.
*
* After removal, this hook polls the status endpoint until the data is confirmed
* as uninstalled
*/
export const useRemove = ({ id, defaultIndex, name, onRemove }: Params): [() => void, boolean] => {
const { removeSampleDataSet, notifyError, notifySuccess } = useServices();
const { removeSampleDataSet, fetchSampleDataSets, notifyError, notifySuccess } = useServices();
const [isRemoving, setIsRemoving] = React.useState(false);

const remove = useCallback(async () => {
try {
setIsRemoving(true);

await removeSampleDataSet(id, defaultIndex);

// Poll until removal is confirmed
await pollForRemoval(id, fetchSampleDataSets, {
maxAttempts: 20,
initialDelayMs: 500,
minTimeout: 500,
factor: 1.5,
});

setIsRemoving(false);

notifySuccess({
Expand All @@ -56,7 +70,16 @@ export const useRemove = ({ id, defaultIndex, name, onRemove }: Params): [() =>
text: `${e.message}`,
});
}
}, [removeSampleDataSet, notifyError, notifySuccess, id, defaultIndex, name, onRemove]);
}, [
removeSampleDataSet,
fetchSampleDataSets,
notifyError,
notifySuccess,
id,
defaultIndex,
name,
onRemove,
]);

return [remove, isRemoving];
};
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export const getStoryServices = (params: Params) => {
action('addBasePath')(path);
return path;
},
fetchSampleDataSets: async () => {
action('fetchSampleDataSets')();
return [mockDataSet];
},
getAppNavigationHandler: (path) => () => action('getAppNavigationHandler')(path),
installSampleDataSet: async (id, defaultIndex) => {
if (simulateErrors) {
Expand Down Expand Up @@ -126,6 +130,7 @@ export const getStoryArgTypes = () => ({
export const getMockServices = (params: Partial<Services> = {}) => {
const services: Services = {
addBasePath: (path) => path,
fetchSampleDataSets: jest.fn(async () => [mockDataSet]),
getAppNavigationHandler: jest.fn(),
installSampleDataSet: jest.fn(),
notifyError: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@ import { getMockServices, getMockDataSet } from './mocks';
import type { Services } from './services';
import { INSTALLED_STATUS, UNINSTALLED_STATUS } from './constants';

// Mock the polling functions to resolve immediately in tests
jest.mock('./hooks/poll_sample_data_status', () => ({
pollForInstallation: jest.fn(async () => Promise.resolve()),
pollForRemoval: jest.fn(async () => Promise.resolve()),
}));

describe('SampleDataCard', () => {
const onStatusChange = jest.fn();
const sampleDataSet = getMockDataSet();

beforeAll(() => jest.resetAllMocks());
beforeEach(() => jest.resetAllMocks());

const render = (element: React.ReactElement, services: Partial<Services> = {}) =>
renderWithIntl(
Expand Down
Loading
Loading