[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:
Scotty Bollinger 2021-10-13 13:44:50 -05:00 committed by GitHub
parent b96f5443d6
commit 8d1c96cd7e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 222 additions and 5 deletions

View file

@ -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' }

View file

@ -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();
});
});

View file

@ -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>

View file

@ -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',
{

View file

@ -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();

View file

@ -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(

View file

@ -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';

View file

@ -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);
};