[App Search] Refactor AppLogic to initialize data via props rather than action (#92841)

* [Misc cleanup] Move Access type to common

- it was being duplicated in server/check_access and InitialAppData

+ add mock access data to DEFAULT_INITIAL_APP_DATA
+ update server/ tests to account for access in DEFAULT_INITIAL_APP_DATA

* Update AppSearchConfigured to pass props to AppLogic vs calling initializeAppData

+ update tests to rerender a wrapper rather than doing {...DEFAULT_INITIAL_APP_DATA} repeatedly

* Update AppLogic to set values from props rather than a listener

- main goal of this PR is to prevent the flash of state between mount and initializeX being called

- note: I recommend turning off whitespace changes in the test file

* Update AppLogic typing so that app data is always expected

- which it should be in any case in a production environment

- note: I could have changed InitialAppData to remove the ? optional notation, but decided on this route instead since InitialAppData affects more than just App Search (e.g. server, WS), and I didn't want this to have potential far-reaching side effects

* Update type scenarios where AppLogic values were previously thought potentially undefined

- which is mostly just configuredLimits it looks like

* [PR feedback] Type name
This commit is contained in:
Constance 2021-03-02 11:25:01 -08:00 committed by GitHub
parent 3f5473ef7d
commit fd3b3eb8cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 106 additions and 147 deletions

View file

@ -23,6 +23,10 @@ export const DEFAULT_INITIAL_APP_DATA = {
}, },
}, },
}, },
access: {
hasAppSearchAccess: true,
hasWorkplaceSearchAccess: true,
},
appSearch: { appSearch: {
accountId: 'some-id-string', accountId: 'some-id-string',
onboardingComplete: true, onboardingComplete: true,

View file

@ -19,10 +19,7 @@ export interface InitialAppData {
ilmEnabled?: boolean; ilmEnabled?: boolean;
isFederatedAuth?: boolean; isFederatedAuth?: boolean;
configuredLimits?: ConfiguredLimits; configuredLimits?: ConfiguredLimits;
access?: { access?: ProductAccess;
hasAppSearchAccess: boolean;
hasWorkplaceSearchAccess: boolean;
};
appSearch?: AppSearchAccount; appSearch?: AppSearchAccount;
workplaceSearch?: WorkplaceSearchInitialData; workplaceSearch?: WorkplaceSearchInitialData;
} }
@ -32,6 +29,11 @@ export interface ConfiguredLimits {
workplaceSearch: WorkplaceSearchConfiguredLimits; workplaceSearch: WorkplaceSearchConfiguredLimits;
} }
export interface ProductAccess {
hasAppSearchAccess: boolean;
hasWorkplaceSearchAccess: boolean;
}
export interface MetaPage { export interface MetaPage {
current: number; current: number;
size: number; size: number;

View file

@ -6,74 +6,68 @@
*/ */
import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__'; import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__';
import { LogicMounter } from '../__mocks__';
import { resetContext } from 'kea';
import { AppLogic } from './app_logic'; import { AppLogic } from './app_logic';
describe('AppLogic', () => { describe('AppLogic', () => {
const { mount } = new LogicMounter(AppLogic); const mount = (props = {}) => {
AppLogic({ ...DEFAULT_INITIAL_APP_DATA, ...props });
beforeEach(() => { AppLogic.mount();
mount();
});
const DEFAULT_VALUES = {
hasInitialized: false,
account: {},
configuredLimits: {},
ilmEnabled: false,
myRole: {},
}; };
it('has expected default values', () => { beforeEach(() => {
expect(AppLogic.values).toEqual(DEFAULT_VALUES); jest.clearAllMocks();
resetContext({});
}); });
describe('initializeAppData()', () => { it('sets values from props', () => {
it('sets values based on passed props', () => { mount();
AppLogic.actions.initializeAppData(DEFAULT_INITIAL_APP_DATA);
expect(AppLogic.values).toEqual({ expect(AppLogic.values).toEqual({
hasInitialized: true, ilmEnabled: true,
ilmEnabled: true, configuredLimits: {
configuredLimits: { engine: {
engine: { maxDocumentByteSize: 102400,
maxDocumentByteSize: 102400, maxEnginesPerMetaEngine: 15,
maxEnginesPerMetaEngine: 15,
},
}, },
account: { },
accountId: 'some-id-string', account: {
onboardingComplete: true, accountId: 'some-id-string',
role: DEFAULT_INITIAL_APP_DATA.appSearch.role, onboardingComplete: true,
}, role: DEFAULT_INITIAL_APP_DATA.appSearch.role,
myRole: expect.objectContaining({ },
id: 'account_id:somestring|user_oid:somestring', myRole: expect.objectContaining({
roleType: 'owner', id: 'account_id:somestring|user_oid:somestring',
availableRoleTypes: ['owner', 'admin'], roleType: 'owner',
credentialTypes: ['admin', 'private', 'search'], availableRoleTypes: ['owner', 'admin'],
canAccessAllEngines: true, credentialTypes: ['admin', 'private', 'search'],
canViewAccountCredentials: true, canAccessAllEngines: true,
// Truncated for brevity - see utils/role/index.test.ts for full output canViewAccountCredentials: true,
}), // Truncated for brevity - see utils/role/index.test.ts for full output
}); }),
}); });
});
it('gracefully handles missing initial data', () => { describe('actions', () => {
AppLogic.actions.initializeAppData({}); describe('setOnboardingComplete()', () => {
it('sets true', () => {
mount({ appSearch: { onboardingComplete: false } });
expect(AppLogic.values).toEqual({ AppLogic.actions.setOnboardingComplete();
...DEFAULT_VALUES, expect(AppLogic.values.account.onboardingComplete).toEqual(true);
hasInitialized: true,
}); });
}); });
}); });
describe('setOnboardingComplete()', () => { describe('selectors', () => {
it('sets true', () => { describe('myRole', () => {
expect(AppLogic.values.account.onboardingComplete).toBeFalsy(); it('falls back to an empty object if role is missing', () => {
AppLogic.actions.setOnboardingComplete(); mount({ appSearch: {} });
expect(AppLogic.values.account.onboardingComplete).toEqual(true);
expect(AppLogic.values.myRole).toEqual({});
});
}); });
}); });
}); });

View file

@ -14,53 +14,33 @@ import { ConfiguredLimits, Account, Role } from './types';
import { getRoleAbilities } from './utils/role'; import { getRoleAbilities } from './utils/role';
interface AppValues { interface AppValues {
hasInitialized: boolean;
ilmEnabled: boolean; ilmEnabled: boolean;
configuredLimits: Partial<ConfiguredLimits>; configuredLimits: ConfiguredLimits;
account: Partial<Account>; account: Account;
myRole: Partial<Role>; myRole: Role;
} }
interface AppActions { interface AppActions {
initializeAppData(props: InitialAppData): Required<InitialAppData>;
setOnboardingComplete(): boolean; setOnboardingComplete(): boolean;
} }
export const AppLogic = kea<MakeLogicType<AppValues, AppActions>>({ export const AppLogic = kea<MakeLogicType<AppValues, AppActions, Required<InitialAppData>>>({
path: ['enterprise_search', 'app_search', 'app_logic'], path: ['enterprise_search', 'app_search', 'app_logic'],
actions: { actions: {
initializeAppData: (props) => props,
setOnboardingComplete: () => true, setOnboardingComplete: () => true,
}, },
reducers: { reducers: ({ props }) => ({
hasInitialized: [
false,
{
initializeAppData: () => true,
},
],
account: [ account: [
{}, props.appSearch,
{ {
initializeAppData: (_, { appSearch: account }) => account || {},
setOnboardingComplete: (account) => ({ setOnboardingComplete: (account) => ({
...account, ...account,
onboardingComplete: true, onboardingComplete: true,
}), }),
}, },
], ],
configuredLimits: [ configuredLimits: [props.configuredLimits.appSearch, {}],
{}, ilmEnabled: [props.ilmEnabled, {}],
{ }),
initializeAppData: (_, { configuredLimits }) => configuredLimits?.appSearch || {},
},
],
ilmEnabled: [
false,
{
initializeAppData: (_, { ilmEnabled }) => !!ilmEnabled,
},
],
},
selectors: { selectors: {
myRole: [ myRole: [
(selectors) => [selectors.account], (selectors) => [selectors.account],

View file

@ -55,8 +55,11 @@ export const FlyoutHeader: React.FC = () => {
}; };
export const FlyoutBody: React.FC = () => { export const FlyoutBody: React.FC = () => {
const { configuredLimits } = useValues(AppLogic); const {
const maxDocumentByteSize = configuredLimits?.engine?.maxDocumentByteSize; configuredLimits: {
engine: { maxDocumentByteSize },
},
} = useValues(AppLogic);
const { textInput, errors } = useValues(DocumentCreationLogic); const { textInput, errors } = useValues(DocumentCreationLogic);
const { setTextInput } = useActions(DocumentCreationLogic); const { setTextInput } = useActions(DocumentCreationLogic);

View file

@ -54,8 +54,11 @@ export const FlyoutHeader: React.FC = () => {
}; };
export const FlyoutBody: React.FC = () => { export const FlyoutBody: React.FC = () => {
const { configuredLimits } = useValues(AppLogic); const {
const maxDocumentByteSize = configuredLimits?.engine?.maxDocumentByteSize; configuredLimits: {
engine: { maxDocumentByteSize },
},
} = useValues(AppLogic);
const { isUploading, errors } = useValues(DocumentCreationLogic); const { isUploading, errors } = useValues(DocumentCreationLogic);
const { setFileInput } = useActions(DocumentCreationLogic); const { setFileInput } = useActions(DocumentCreationLogic);

View file

@ -56,7 +56,7 @@ const comboBoxOptionToEngineName = (option: EuiComboBoxOptionOption<string>): st
export const MetaEngineCreation: React.FC = () => { export const MetaEngineCreation: React.FC = () => {
const { const {
configuredLimits: { configuredLimits: {
engine: { maxEnginesPerMetaEngine } = { maxEnginesPerMetaEngine: Infinity }, engine: { maxEnginesPerMetaEngine },
}, },
} = useValues(AppLogic); } = useValues(AppLogic);

View file

@ -5,18 +5,21 @@
* 2.0. * 2.0.
*/ */
import '../__mocks__/shallow_useeffect.mock'; import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__';
import '../__mocks__/enterprise_search_url.mock'; import '../__mocks__/enterprise_search_url.mock';
import { setMockValues, setMockActions } from '../__mocks__'; import { setMockValues, rerender } from '../__mocks__';
import React from 'react'; import React from 'react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { shallow } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Layout, SideNav, SideNavLink } from '../shared/layout'; import { Layout, SideNav, SideNavLink } from '../shared/layout';
jest.mock('./app_logic', () => ({ AppLogic: jest.fn() }));
import { AppLogic } from './app_logic';
import { EngineRouter } from './components/engine'; import { EngineRouter } from './components/engine';
import { EngineCreation } from './components/engine_creation'; import { EngineCreation } from './components/engine_creation';
import { EnginesOverview } from './components/engines'; import { EnginesOverview } from './components/engines';
@ -52,52 +55,34 @@ describe('AppSearchUnconfigured', () => {
}); });
describe('AppSearchConfigured', () => { describe('AppSearchConfigured', () => {
beforeEach(() => { let wrapper: ShallowWrapper;
// Mock resets
beforeAll(() => {
setMockValues({ myRole: {} }); setMockValues({ myRole: {} });
setMockActions({ initializeAppData: () => {} }); wrapper = shallow(<AppSearchConfigured {...DEFAULT_INITIAL_APP_DATA} />);
}); });
it('renders with layout', () => { it('renders with layout', () => {
const wrapper = shallow(<AppSearchConfigured />);
expect(wrapper.find(Layout)).toHaveLength(2); expect(wrapper.find(Layout)).toHaveLength(2);
expect(wrapper.find(Layout).last().prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(Layout).last().prop('readOnlyMode')).toBeFalsy();
expect(wrapper.find(EnginesOverview)).toHaveLength(1); expect(wrapper.find(EnginesOverview)).toHaveLength(1);
expect(wrapper.find(EngineRouter)).toHaveLength(1); expect(wrapper.find(EngineRouter)).toHaveLength(1);
}); });
it('initializes app data with passed props', () => { it('mounts AppLogic with passed initial data props', () => {
const initializeAppData = jest.fn(); expect(AppLogic).toHaveBeenCalledWith(DEFAULT_INITIAL_APP_DATA);
setMockActions({ initializeAppData });
shallow(<AppSearchConfigured ilmEnabled />);
expect(initializeAppData).toHaveBeenCalledWith({ ilmEnabled: true });
});
it('does not re-initialize app data', () => {
const initializeAppData = jest.fn();
setMockActions({ initializeAppData });
setMockValues({ myRole: {}, hasInitialized: true });
shallow(<AppSearchConfigured />);
expect(initializeAppData).not.toHaveBeenCalled();
}); });
it('renders ErrorConnecting', () => { it('renders ErrorConnecting', () => {
setMockValues({ myRole: {}, errorConnecting: true }); setMockValues({ myRole: {}, errorConnecting: true });
rerender(wrapper);
const wrapper = shallow(<AppSearchConfigured />);
expect(wrapper.find(ErrorConnecting)).toHaveLength(1); expect(wrapper.find(ErrorConnecting)).toHaveLength(1);
}); });
it('passes readOnlyMode state', () => { it('passes readOnlyMode state', () => {
setMockValues({ myRole: {}, readOnlyMode: true }); setMockValues({ myRole: {}, readOnlyMode: true });
rerender(wrapper);
const wrapper = shallow(<AppSearchConfigured />);
expect(wrapper.find(Layout).first().prop('readOnlyMode')).toEqual(true); expect(wrapper.find(Layout).first().prop('readOnlyMode')).toEqual(true);
}); });
@ -106,14 +91,14 @@ describe('AppSearchConfigured', () => {
describe('canManageEngines', () => { describe('canManageEngines', () => {
it('renders EngineCreation when user canManageEngines is true', () => { it('renders EngineCreation when user canManageEngines is true', () => {
setMockValues({ myRole: { canManageEngines: true } }); setMockValues({ myRole: { canManageEngines: true } });
const wrapper = shallow(<AppSearchConfigured />); rerender(wrapper);
expect(wrapper.find(EngineCreation)).toHaveLength(1); expect(wrapper.find(EngineCreation)).toHaveLength(1);
}); });
it('does not render EngineCreation when user canManageEngines is false', () => { it('does not render EngineCreation when user canManageEngines is false', () => {
setMockValues({ myRole: { canManageEngines: false } }); setMockValues({ myRole: { canManageEngines: false } });
const wrapper = shallow(<AppSearchConfigured />); rerender(wrapper);
expect(wrapper.find(EngineCreation)).toHaveLength(0); expect(wrapper.find(EngineCreation)).toHaveLength(0);
}); });
@ -122,14 +107,14 @@ describe('AppSearchConfigured', () => {
describe('canManageMetaEngines', () => { describe('canManageMetaEngines', () => {
it('renders MetaEngineCreation when user canManageMetaEngines is true', () => { it('renders MetaEngineCreation when user canManageMetaEngines is true', () => {
setMockValues({ myRole: { canManageMetaEngines: true } }); setMockValues({ myRole: { canManageMetaEngines: true } });
const wrapper = shallow(<AppSearchConfigured />); rerender(wrapper);
expect(wrapper.find(MetaEngineCreation)).toHaveLength(1); expect(wrapper.find(MetaEngineCreation)).toHaveLength(1);
}); });
it('does not render MetaEngineCreation when user canManageMetaEngines is false', () => { it('does not render MetaEngineCreation when user canManageMetaEngines is false', () => {
setMockValues({ myRole: { canManageMetaEngines: false } }); setMockValues({ myRole: { canManageMetaEngines: false } });
const wrapper = shallow(<AppSearchConfigured />); rerender(wrapper);
expect(wrapper.find(MetaEngineCreation)).toHaveLength(0); expect(wrapper.find(MetaEngineCreation)).toHaveLength(0);
}); });

View file

@ -5,10 +5,10 @@
* 2.0. * 2.0.
*/ */
import React, { useEffect } from 'react'; import React from 'react';
import { Route, Redirect, Switch } from 'react-router-dom'; import { Route, Redirect, Switch } from 'react-router-dom';
import { useActions, useValues } from 'kea'; import { useValues } from 'kea';
import { APP_SEARCH_PLUGIN } from '../../../common/constants'; import { APP_SEARCH_PLUGIN } from '../../../common/constants';
import { InitialAppData } from '../../../common/types'; import { InitialAppData } from '../../../common/types';
@ -44,7 +44,11 @@ import {
export const AppSearch: React.FC<InitialAppData> = (props) => { export const AppSearch: React.FC<InitialAppData> = (props) => {
const { config } = useValues(KibanaLogic); const { config } = useValues(KibanaLogic);
return !config.host ? <AppSearchUnconfigured /> : <AppSearchConfigured {...props} />; return !config.host ? (
<AppSearchUnconfigured />
) : (
<AppSearchConfigured {...(props as Required<InitialAppData>)} />
);
}; };
export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchUnconfigured: React.FC = () => (
@ -58,18 +62,12 @@ export const AppSearchUnconfigured: React.FC = () => (
</Switch> </Switch>
); );
export const AppSearchConfigured: React.FC<InitialAppData> = (props) => { export const AppSearchConfigured: React.FC<Required<InitialAppData>> = (props) => {
const { initializeAppData } = useActions(AppLogic);
const { const {
hasInitialized,
myRole: { canManageEngines, canManageMetaEngines }, myRole: { canManageEngines, canManageMetaEngines },
} = useValues(AppLogic); } = useValues(AppLogic(props));
const { errorConnecting, readOnlyMode } = useValues(HttpLogic); const { errorConnecting, readOnlyMode } = useValues(HttpLogic);
useEffect(() => {
if (!hasInitialized) initializeAppData(props);
}, [hasInitialized]);
return ( return (
<Switch> <Switch>
<Route exact path={SETUP_GUIDE_PATH}> <Route exact path={SETUP_GUIDE_PATH}>

View file

@ -9,6 +9,7 @@ import { KibanaRequest, Logger } from 'src/core/server';
import { SecurityPluginSetup } from '../../../security/server'; import { SecurityPluginSetup } from '../../../security/server';
import { SpacesPluginStart } from '../../../spaces/server'; import { SpacesPluginStart } from '../../../spaces/server';
import { ProductAccess } from '../../common/types';
import { ConfigType } from '../index'; import { ConfigType } from '../index';
import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api';
@ -20,10 +21,6 @@ interface CheckAccess {
config: ConfigType; config: ConfigType;
log: Logger; log: Logger;
} }
export interface Access {
hasAppSearchAccess: boolean;
hasWorkplaceSearchAccess: boolean;
}
const ALLOW_ALL_PLUGINS = { const ALLOW_ALL_PLUGINS = {
hasAppSearchAccess: true, hasAppSearchAccess: true,
@ -45,7 +42,7 @@ export const checkAccess = async ({
spaces, spaces,
request, request,
log, log,
}: CheckAccess): Promise<Access> => { }: CheckAccess): Promise<ProductAccess> => {
const isRbacEnabled = security?.authz.mode.useRbacForRequest(request) ?? false; const isRbacEnabled = security?.authz.mode.useRbacForRequest(request) ?? false;
// We can only retrieve the active space when either: // We can only retrieve the active space when either:

View file

@ -108,12 +108,12 @@ describe('callEnterpriseSearchConfigAPI', () => {
}); });
expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({ expect(await callEnterpriseSearchConfigAPI(mockDependencies)).toEqual({
...DEFAULT_INITIAL_APP_DATA,
access: { access: {
hasAppSearchAccess: true, hasAppSearchAccess: true,
hasWorkplaceSearchAccess: false, hasWorkplaceSearchAccess: false,
}, },
publicUrl: 'http://some.vanity.url', publicUrl: 'http://some.vanity.url',
...DEFAULT_INITIAL_APP_DATA,
}); });
}); });

View file

@ -14,15 +14,12 @@ import { stripTrailingSlash } from '../../common/strip_slashes';
import { InitialAppData } from '../../common/types'; import { InitialAppData } from '../../common/types';
import { ConfigType } from '../index'; import { ConfigType } from '../index';
import { Access } from './check_access';
interface Params { interface Params {
request: KibanaRequest; request: KibanaRequest;
config: ConfigType; config: ConfigType;
log: Logger; log: Logger;
} }
interface Return extends InitialAppData { interface Return extends InitialAppData {
access?: Access;
publicUrl?: string; publicUrl?: string;
} }

View file

@ -33,12 +33,8 @@ describe('Enterprise Search Config Data API', () => {
describe('GET /api/enterprise_search/config_data', () => { describe('GET /api/enterprise_search/config_data', () => {
it('returns an initial set of config data from Enterprise Search', async () => { it('returns an initial set of config data from Enterprise Search', async () => {
const mockData = { const mockData = {
access: {
hasAppSearchAccess: true,
hasWorkplaceSearchAccess: true,
},
publicUrl: 'http://localhost:3002',
...DEFAULT_INITIAL_APP_DATA, ...DEFAULT_INITIAL_APP_DATA,
publicUrl: 'http://localhost:3002',
}; };
(callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => { (callEnterpriseSearchConfigAPI as jest.Mock).mockImplementationOnce(() => {