[Workplace Search] Add Synchronize button to Source Overview page (#114842)
* Add sync route * Add logic for triggering sync on server * Add button with confirm modal and description w/links
This commit is contained in:
parent
b96f5443d6
commit
8d1c96cd7e
|
@ -35,6 +35,11 @@ export const CANCEL_BUTTON_LABEL = i18n.translate(
|
|||
{ defaultMessage: 'Cancel' }
|
||||
);
|
||||
|
||||
export const START_BUTTON_LABEL = i18n.translate(
|
||||
'xpack.enterpriseSearch.actions.startButtonLabel',
|
||||
{ defaultMessage: 'Start' }
|
||||
);
|
||||
|
||||
export const CONTINUE_BUTTON_LABEL = i18n.translate(
|
||||
'xpack.enterpriseSearch.actions.continueButtonLabel',
|
||||
{ defaultMessage: 'Continue' }
|
||||
|
|
|
@ -5,20 +5,21 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { setMockValues } from '../../../../__mocks__/kea_logic';
|
||||
import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic';
|
||||
import { fullContentSources } from '../../../__mocks__/content_sources.mock';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiEmptyPrompt, EuiPanel, EuiTable } from '@elastic/eui';
|
||||
import { EuiConfirmModal, EuiEmptyPrompt, EuiPanel, EuiTable } from '@elastic/eui';
|
||||
|
||||
import { ComponentLoader } from '../../../components/shared/component_loader';
|
||||
|
||||
import { Overview } from './overview';
|
||||
|
||||
describe('Overview', () => {
|
||||
const initializeSourceSynchronization = jest.fn();
|
||||
const contentSource = fullContentSources[0];
|
||||
const dataLoading = false;
|
||||
const isOrganization = true;
|
||||
|
@ -31,6 +32,7 @@ describe('Overview', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
setMockValues({ ...mockValues });
|
||||
setMockActions({ initializeSourceSynchronization });
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
|
@ -118,4 +120,14 @@ describe('Overview', () => {
|
|||
|
||||
expect(wrapper.find('[data-test-subj="DocumentPermissionsDisabled"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles confirmModal submission', () => {
|
||||
const wrapper = shallow(<Overview />);
|
||||
const button = wrapper.find('[data-test-subj="SyncButton"]');
|
||||
button.prop('onClick')!({} as any);
|
||||
const modal = wrapper.find(EuiConfirmModal);
|
||||
modal.prop('onConfirm')!({} as any);
|
||||
|
||||
expect(initializeSourceSynchronization).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,11 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useValues } from 'kea';
|
||||
import { useValues, useActions } from 'kea';
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiConfirmModal,
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -30,7 +32,8 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { EuiListGroupItemTo } from '../../../../shared/react_router_helpers';
|
||||
import { CANCEL_BUTTON_LABEL, START_BUTTON_LABEL } from '../../../../shared/constants';
|
||||
import { EuiListGroupItemTo, EuiLinkTo } from '../../../../shared/react_router_helpers';
|
||||
import { AppLogic } from '../../../app_logic';
|
||||
import aclImage from '../../../assets/supports_acl.svg';
|
||||
import { ComponentLoader } from '../../../components/shared/component_loader';
|
||||
|
@ -48,7 +51,10 @@ import {
|
|||
DOCUMENT_PERMISSIONS_DOCS_URL,
|
||||
ENT_SEARCH_LICENSE_MANAGEMENT,
|
||||
EXTERNAL_IDENTITIES_DOCS_URL,
|
||||
SYNC_FREQUENCY_PATH,
|
||||
BLOCKED_TIME_WINDOWS_PATH,
|
||||
getGroupPath,
|
||||
getContentSourcePath,
|
||||
} from '../../../routes';
|
||||
import {
|
||||
SOURCES_NO_CONTENT_TITLE,
|
||||
|
@ -77,6 +83,12 @@ import {
|
|||
LEARN_CUSTOM_FEATURES_BUTTON,
|
||||
DOC_PERMISSIONS_DESCRIPTION,
|
||||
CUSTOM_CALLOUT_TITLE,
|
||||
SOURCE_SYNCHRONIZATION_TITLE,
|
||||
SOURCE_SYNC_FREQUENCY_LINK_LABEL,
|
||||
SOURCE_BLOCKED_TIME_WINDOWS_LINK_LABEL,
|
||||
SOURCE_SYNCHRONIZATION_BUTTON_LABEL,
|
||||
SOURCE_SYNC_CONFIRM_TITLE,
|
||||
SOURCE_SYNC_CONFIRM_MESSAGE,
|
||||
} from '../constants';
|
||||
import { SourceLogic } from '../source_logic';
|
||||
|
||||
|
@ -84,6 +96,7 @@ import { SourceLayout } from './source_layout';
|
|||
|
||||
export const Overview: React.FC = () => {
|
||||
const { contentSource } = useValues(SourceLogic);
|
||||
const { initializeSourceSynchronization } = useActions(SourceLogic);
|
||||
const { isOrganization } = useValues(AppLogic);
|
||||
|
||||
const {
|
||||
|
@ -99,8 +112,20 @@ export const Overview: React.FC = () => {
|
|||
indexPermissions,
|
||||
hasPermissions,
|
||||
isFederatedSource,
|
||||
isIndexedSource,
|
||||
} = contentSource;
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const closeModal = () => setIsModalVisible(false);
|
||||
const handleSyncClick = () => setIsModalVisible(true);
|
||||
|
||||
const onSyncConfirm = () => {
|
||||
initializeSourceSynchronization(id);
|
||||
setIsSyncing(true);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const DocumentSummary = () => {
|
||||
let totalDocuments = 0;
|
||||
const tableContent = summary?.map((item, index) => {
|
||||
|
@ -451,9 +476,57 @@ export const Overview: React.FC = () => {
|
|||
</EuiPanel>
|
||||
);
|
||||
|
||||
const syncTriggerCallout = (
|
||||
<EuiFlexItem>
|
||||
<EuiSpacer />
|
||||
<EuiTitle size="xs">
|
||||
<h5>{SOURCE_SYNCHRONIZATION_TITLE}</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiPanel color="subdued">
|
||||
<EuiButton fill isLoading={isSyncing} onClick={handleSyncClick} data-test-subj="SyncButton">
|
||||
{SOURCE_SYNCHRONIZATION_BUTTON_LABEL}
|
||||
</EuiButton>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.enterpriseSearch.workplaceSearch.sources.synchronizationCallout"
|
||||
defaultMessage="Configure {syncFrequencyLink} or permissions {blockTimeWindowsLink}."
|
||||
values={{
|
||||
syncFrequencyLink: (
|
||||
<EuiLinkTo to={getContentSourcePath(SYNC_FREQUENCY_PATH, id, isOrganization)}>
|
||||
{SOURCE_SYNC_FREQUENCY_LINK_LABEL}
|
||||
</EuiLinkTo>
|
||||
),
|
||||
blockTimeWindowsLink: (
|
||||
<EuiLinkTo to={getContentSourcePath(BLOCKED_TIME_WINDOWS_PATH, id, isOrganization)}>
|
||||
{SOURCE_BLOCKED_TIME_WINDOWS_LINK_LABEL}
|
||||
</EuiLinkTo>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
|
||||
const syncConfirmModal = (
|
||||
<EuiConfirmModal
|
||||
title={SOURCE_SYNC_CONFIRM_TITLE}
|
||||
onCancel={closeModal}
|
||||
onConfirm={onSyncConfirm}
|
||||
cancelButtonText={CANCEL_BUTTON_LABEL}
|
||||
confirmButtonText={START_BUTTON_LABEL}
|
||||
defaultFocusedButton="confirm"
|
||||
>
|
||||
<p>{SOURCE_SYNC_CONFIRM_MESSAGE}</p>
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
|
||||
return (
|
||||
<SourceLayout pageViewTelemetry="source_overview">
|
||||
<ViewContentHeader title={SOURCE_OVERVIEW_TITLE} />
|
||||
{isModalVisible && syncConfirmModal}
|
||||
|
||||
<EuiFlexGroup gutterSize="xl" alignItems="flexStart">
|
||||
<EuiFlexItem grow={8}>
|
||||
|
@ -513,6 +586,7 @@ export const Overview: React.FC = () => {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{isIndexedSource && isOrganization && syncTriggerCallout}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -579,6 +579,49 @@ export const SOURCE_SYNCHRONIZATION_FREQUENCY_TITLE = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const SOURCE_SYNCHRONIZATION_TITLE = i18n.translate(
|
||||
'xpack.enterpriseSearch.workplaceSearch.sources.sourceSynchronizationTitle',
|
||||
{
|
||||
defaultMessage: 'Synchronization',
|
||||
}
|
||||
);
|
||||
|
||||
export const SOURCE_SYNCHRONIZATION_BUTTON_LABEL = i18n.translate(
|
||||
'xpack.enterpriseSearch.workplaceSearch.sources.sourceSynchronizationButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Synchronize content',
|
||||
}
|
||||
);
|
||||
|
||||
export const SOURCE_SYNC_FREQUENCY_LINK_LABEL = i18n.translate(
|
||||
'xpack.enterpriseSearch.workplaceSearch.sources.sourceSyncFrequencyLinkLabel',
|
||||
{
|
||||
defaultMessage: 'sync frequency',
|
||||
}
|
||||
);
|
||||
|
||||
export const SOURCE_BLOCKED_TIME_WINDOWS_LINK_LABEL = i18n.translate(
|
||||
'xpack.enterpriseSearch.workplaceSearch.sources.sourceBlockedTimeWindowsLinkLabel',
|
||||
{
|
||||
defaultMessage: 'blocked time windows',
|
||||
}
|
||||
);
|
||||
|
||||
export const SOURCE_SYNC_CONFIRM_TITLE = i18n.translate(
|
||||
'xpack.enterpriseSearch.workplaceSearch.sources.sourceSyncConfirmTitle',
|
||||
{
|
||||
defaultMessage: 'Start new content sync?',
|
||||
}
|
||||
);
|
||||
|
||||
export const SOURCE_SYNC_CONFIRM_MESSAGE = i18n.translate(
|
||||
'xpack.enterpriseSearch.workplaceSearch.sources.sourceSyncConfirmMessage',
|
||||
{
|
||||
defaultMessage:
|
||||
'Are you sure you would like to continue with this request and stop all other syncs?',
|
||||
}
|
||||
);
|
||||
|
||||
export const SOURCE_SYNC_FREQUENCY_TITLE = i18n.translate(
|
||||
'xpack.enterpriseSearch.workplaceSearch.sources.sourceSyncFrequencyTitle',
|
||||
{
|
||||
|
|
|
@ -429,6 +429,34 @@ describe('SourceLogic', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('initializeSourceSynchronization', () => {
|
||||
it('calls API and fetches fresh source state', async () => {
|
||||
const initializeSourceSpy = jest.spyOn(SourceLogic.actions, 'initializeSource');
|
||||
const promise = Promise.resolve(contentSource);
|
||||
http.post.mockReturnValue(promise);
|
||||
SourceLogic.actions.initializeSourceSynchronization(contentSource.id);
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/org/sources/123/sync');
|
||||
await promise;
|
||||
expect(initializeSourceSpy).toHaveBeenCalledWith(contentSource.id);
|
||||
});
|
||||
|
||||
it('handles error', async () => {
|
||||
const error = {
|
||||
response: {
|
||||
error: 'this is an error',
|
||||
status: 400,
|
||||
},
|
||||
};
|
||||
const promise = Promise.reject(error);
|
||||
http.post.mockReturnValue(promise);
|
||||
SourceLogic.actions.initializeSourceSynchronization(contentSource.id);
|
||||
await expectedAsyncError(promise);
|
||||
|
||||
expect(flashAPIErrors).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('resetSourceState', () => {
|
||||
SourceLogic.actions.resetSourceState();
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ export interface SourceActions {
|
|||
onUpdateSourceName(name: string): string;
|
||||
setSearchResults(searchResultsResponse: SearchResultsResponse): SearchResultsResponse;
|
||||
initializeFederatedSummary(sourceId: string): { sourceId: string };
|
||||
initializeSourceSynchronization(sourceId: string): { sourceId: string };
|
||||
onUpdateSummary(summary: DocumentSummaryItem[]): DocumentSummaryItem[];
|
||||
setContentFilterValue(contentFilterValue: string): string;
|
||||
setActivePage(activePage: number): number;
|
||||
|
@ -81,6 +82,7 @@ export const SourceLogic = kea<MakeLogicType<SourceValues, SourceActions>>({
|
|||
setActivePage: (activePage: number) => activePage,
|
||||
initializeSource: (sourceId: string) => ({ sourceId }),
|
||||
initializeFederatedSummary: (sourceId: string) => ({ sourceId }),
|
||||
initializeSourceSynchronization: (sourceId: string) => ({ sourceId }),
|
||||
searchContentSourceDocuments: (sourceId: string) => ({ sourceId }),
|
||||
updateContentSource: (sourceId: string, source: SourceUpdatePayload) => ({ sourceId, source }),
|
||||
removeContentSource: (sourceId: string) => ({
|
||||
|
@ -254,6 +256,15 @@ export const SourceLogic = kea<MakeLogicType<SourceValues, SourceActions>>({
|
|||
actions.setButtonNotLoading();
|
||||
}
|
||||
},
|
||||
initializeSourceSynchronization: async ({ sourceId }) => {
|
||||
const route = `/internal/workplace_search/org/sources/${sourceId}/sync`;
|
||||
try {
|
||||
await HttpLogic.values.http.post(route);
|
||||
actions.initializeSource(sourceId);
|
||||
} catch (e) {
|
||||
flashAPIErrors(e);
|
||||
}
|
||||
},
|
||||
onUpdateSourceName: (name: string) => {
|
||||
flashSuccessToast(
|
||||
i18n.translate(
|
||||
|
|
|
@ -42,6 +42,7 @@ import {
|
|||
registerOrgSourceDownloadDiagnosticsRoute,
|
||||
registerOrgSourceOauthConfigurationsRoute,
|
||||
registerOrgSourceOauthConfigurationRoute,
|
||||
registerOrgSourceSynchronizeRoute,
|
||||
registerOauthConnectorParamsRoute,
|
||||
} from './sources';
|
||||
|
||||
|
@ -1252,6 +1253,29 @@ describe('sources routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('POST /internal/workplace_search/org/sources/{id}/sync', () => {
|
||||
let mockRouter: MockRouter;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRouter = new MockRouter({
|
||||
method: 'post',
|
||||
path: '/internal/workplace_search/org/sources/{id}/sync',
|
||||
});
|
||||
|
||||
registerOrgSourceSynchronizeRoute({
|
||||
...mockDependencies,
|
||||
router: mockRouter.router,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a request handler', () => {
|
||||
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
|
||||
path: '/ws/org/sources/:id/sync',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /internal/workplace_search/sources/create', () => {
|
||||
const tokenPackage = 'some_encrypted_secrets';
|
||||
|
||||
|
|
|
@ -891,6 +891,25 @@ export function registerOrgSourceOauthConfigurationRoute({
|
|||
);
|
||||
}
|
||||
|
||||
export function registerOrgSourceSynchronizeRoute({
|
||||
router,
|
||||
enterpriseSearchRequestHandler,
|
||||
}: RouteDependencies) {
|
||||
router.post(
|
||||
{
|
||||
path: '/internal/workplace_search/org/sources/{id}/sync',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
id: schema.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
enterpriseSearchRequestHandler.createRequest({
|
||||
path: '/ws/org/sources/:id/sync',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Same route is used for org and account. `state` passes the context.
|
||||
export function registerOauthConnectorParamsRoute({
|
||||
router,
|
||||
|
@ -956,5 +975,6 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => {
|
|||
registerOrgSourceDownloadDiagnosticsRoute(dependencies);
|
||||
registerOrgSourceOauthConfigurationsRoute(dependencies);
|
||||
registerOrgSourceOauthConfigurationRoute(dependencies);
|
||||
registerOrgSourceSynchronizeRoute(dependencies);
|
||||
registerOauthConnectorParamsRoute(dependencies);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue