Skip to content

Commit

Permalink
rework tab modal component
Browse files Browse the repository at this point in the history
  • Loading branch information
eokoneyo committed Mar 28, 2024
1 parent 9d0359a commit 93706d5
Show file tree
Hide file tree
Showing 8 changed files with 315 additions and 78 deletions.
19 changes: 19 additions & 0 deletions packages/shared-ux/modal/tabbed/README.mdx
@@ -0,0 +1,19 @@
---
id: sharedUX/Page/TabbedModal
slug: /shared-ux/modal/tabbed
title: Kibana Tabbed Modal
description: A component for rendering tabs within a modal.
tags: ['shared-ux', 'component']
date: 2024-04-28
---

## API

| Export | Description |
|---|---| |
| `TabbedModal` | component that renders tab content within a modal in kibana.
| `IModalTabDeclaration` | type definition for defining the content of a modal tab, also provides support to make the tab content controlled and have it's state if needed be managed by the modal.

## EUI Promotion Status

This component is not currently considered for promotion to EUI.
3 changes: 1 addition & 2 deletions packages/shared-ux/modal/tabbed/index.tsx
Expand Up @@ -6,5 +6,4 @@
* Side Public License, v 1.
*/

export { TabbedModal } from './src/tabbed_modal';
export type { IModalTabDeclaration } from './src/context';
export { TabbedModal, type IModalTabDeclaration } from './src/tabbed_modal';
13 changes: 13 additions & 0 deletions packages/shared-ux/modal/tabbed/jest.config.js
@@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/

module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/packages/shared-ux/modal/tabbed'],
};
110 changes: 110 additions & 0 deletions packages/shared-ux/modal/tabbed/src/context/index.test.tsx
@@ -0,0 +1,110 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/

import React, { type ComponentProps, type ComponentType } from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useModalContext, ModalContextProvider } from '.';

type ModalContextProviderProps = ComponentProps<typeof ModalContextProvider>;

function createModalContextWrapper<T extends ComponentType>(props: ModalContextProviderProps) {
return function CreatedWrapper({ children }) {
return <ModalContextProvider {...props}>{children}</ModalContextProvider>;
};
}

describe('tabbed modal provider', () => {
it('creates a default internal state of specific shape', () => {
const props: ModalContextProviderProps = {
tabs: [
{
id: 'test',
name: 'Test',
},
],
defaultSelectedTabId: 'test',
};

const { result } = renderHook(useModalContext, {
wrapper: createModalContextWrapper(props),
});

expect(result.current).toHaveProperty(
'tabs',
([] as ModalContextProviderProps['tabs']).concat(props.tabs).map((tab) => {
if (tab.initialState) {
delete tab.initialState;
}

return tab;
})
);

expect(result.current).toHaveProperty(
'state',
expect.objectContaining({
meta: expect.objectContaining({ selectedTabId: props.defaultSelectedTabId }),
test: {},
})
);

expect(result.current).toHaveProperty('dispatch', expect.any(Function));
});

it('invocating the context dispatch function causes state changes for the selected tab state', () => {
const SUT_DISPATCH_ACTION = {
type: 'TEST_ACTION',
payload: 'state_update',
};

const props: ModalContextProviderProps = {
tabs: [
{
id: 'test',
name: 'Test',
reducer: (state = {}, action) => {
switch (action.type) {
case SUT_DISPATCH_ACTION.type:
return {
...state,
sut: action.payload,
};
default:
return state;
}
},
},
],
defaultSelectedTabId: 'test',
};

const { result } = renderHook(useModalContext, {
wrapper: createModalContextWrapper(props),
});

expect(result.current).toHaveProperty(
'state',
expect.objectContaining({
test: {},
})
);

act(() => {
result.current.dispatch(SUT_DISPATCH_ACTION);
});

expect(result.current).toHaveProperty(
'state',
expect.objectContaining({
test: {
sut: SUT_DISPATCH_ACTION.payload,
},
})
);
});
});
111 changes: 54 additions & 57 deletions packages/shared-ux/modal/tabbed/src/context/index.tsx
Expand Up @@ -14,71 +14,59 @@ import React, {
useRef,
useCallback,
type PropsWithChildren,
type ReactElement,
type Dispatch,
} from 'react';
import { type EuiTabProps, type CommonProps } from '@elastic/eui';
import { once } from 'lodash';

interface IDispatchAction {
type: string;
payload: any;
}

export type IModalTabState = Record<string, unknown>;
export type IDispatchFunction = Dispatch<IDispatchAction>;

// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type IModalMetaState = {
export interface IMetaState {
selectedTabId: string | null;
};

type IReducer<S extends IModalTabState> = (state: S, action: IDispatchAction) => S;

export type IModalTabContent<S extends IModalTabState> = (props: {
state: S;
dispatch: Dispatch<IDispatchAction>;
}) => ReactElement;

interface IModalTabActionBtn<S> extends CommonProps {
id: string;
dataTestSubj: string;
defaultMessage: string;
formattedMessageId: string;
handler: (args: { state: S }) => void;
isCopy?: boolean;
}

export interface IModalTabDeclaration<S extends IModalTabState> extends EuiTabProps {
type IReducer<S> = (state: S, action: IDispatchAction) => S;

export interface ITabDeclaration<S = {}> {
id: string;
name: string;
initialState?: Partial<S>;
reducer?: IReducer<S>;
description?: ReactElement;
'data-test-subj'?: string;
content?: IModalTabContent<S>;
modalActionBtn: IModalTabActionBtn<S>;
}

interface IModalContext<S extends IModalTabState = IModalTabState> {
tabs: Array<Exclude<IModalTabDeclaration<S>, 'reducer' | 'initialState'>>;
state: { meta: IModalMetaState } & Record<string, S>;
interface IModalContext<T extends Array<ITabDeclaration<Record<string, any>>>> {
tabs: Array<Omit<T[number], 'reducer' | 'initialState'>>;
state: {
meta: IMetaState;
[index: string]: any;
};
dispatch: Dispatch<IDispatchAction>;
}

const ModalContext = createContext<IModalContext>({
tabs: [],
state: {
meta: {
selectedTabId: null,
const createStateContext = once(<T extends Array<ITabDeclaration<Record<string, any>>>>() =>
createContext({
tabs: [],
state: {
meta: {
selectedTabId: null,
},
},
},
dispatch: () => {},
});
dispatch: () => {},
} as IModalContext<T>)
);

export const useModalContext = <T extends Array<ITabDeclaration<Record<string, any>>>>() =>
useContext(createStateContext<T>());

/**
* @description defines state transition for meta information to manage the modal, meta action types
* must be prefixed with the string 'META_'
*/
const modalMetaReducer: IReducer<IModalMetaState> = (state, action) => {
const modalMetaReducer: IReducer<IMetaState> = (state, action) => {
switch (action.type) {
case 'META_selectedTabId':
return {
Expand All @@ -90,20 +78,30 @@ const modalMetaReducer: IReducer<IModalMetaState> = (state, action) => {
}
};

export type IModalContextProviderProps<Tabs extends Array<IModalTabDeclaration<IModalTabState>>> =
export type IModalContextProviderProps<Tabs extends Array<ITabDeclaration<Record<string, any>>>> =
PropsWithChildren<{
/**
* Array of tab declaration to be rendered into the modal that will be rendered
*/
tabs: Tabs;
/**
* ID of the tab we'd like the modal to have selected on render
*/
defaultSelectedTabId: Tabs[number]['id'];
}>;

export function ModalContextProvider<T extends Array<IModalTabDeclaration<IModalTabState>>>({
export function ModalContextProvider<T extends Array<ITabDeclaration<Record<string, any>>>>({
tabs,
defaultSelectedTabId,
children,
}: IModalContextProviderProps<T>) {
const modalTabDefinitions = useRef<IModalContext['tabs']>([]);
const ModalContext = createStateContext<T>();

type IModalInstanceContext = IModalContext<T>;

const initialModalState = useRef<IModalContext['state']>({
const modalTabDefinitions = useRef<IModalInstanceContext['tabs']>([]);

const initialModalState = useRef<IModalInstanceContext['state']>({
// instantiate state with default meta information
meta: {
selectedTabId: defaultSelectedTabId,
Expand All @@ -112,35 +110,36 @@ export function ModalContextProvider<T extends Array<IModalTabDeclaration<IModal

const reducersMap = useMemo(
() =>
tabs.reduce((result, { id, reducer, initialState, ...rest }) => {
initialModalState.current[id] = initialState ?? {};
modalTabDefinitions.current.push({ id, reducer, ...rest });
result[id] = reducer;
tabs.reduce((result, { reducer, initialState, ...rest }) => {
initialModalState.current[rest.id] = initialState ?? {};
// @ts-ignore
modalTabDefinitions.current.push({ ...rest });
result[rest.id] = reducer;
return result;
}, {}),
}, {} as Record<string, T[number]['reducer']>),
[tabs]
);

const combineReducers = useCallback(function (
reducers: Record<string, IReducer<IModalTabState>>
) {
return (state: IModalContext['state'], action: IDispatchAction) => {
const combineReducers = useCallback(function (reducers: Record<string, T[number]['reducer']>) {
return (state: IModalInstanceContext['state'], action: IDispatchAction) => {
const newState = { ...state };

if (/^meta_/i.test(action.type)) {
newState.meta = modalMetaReducer(newState.meta, action);
} else {
const selectedTabId = state.meta.selectedTabId!;
const selectedTabReducer = reducers[selectedTabId];

newState[selectedTabId] = reducers[selectedTabId](newState[selectedTabId], action);
if (selectedTabReducer) {
newState[selectedTabId] = selectedTabReducer(newState[selectedTabId], action);
}
}

return newState;
};
},
[]);
}, []);

const createInitialState = useCallback((state: IModalContext['state']) => {
const createInitialState = useCallback((state: IModalInstanceContext['state']) => {
return state;
}, []);

Expand All @@ -156,5 +155,3 @@ export function ModalContextProvider<T extends Array<IModalTabDeclaration<IModal
</ModalContext.Provider>
);
}

export const useModalContext = () => useContext(ModalContext);
15 changes: 5 additions & 10 deletions packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx
Expand Up @@ -5,17 +5,15 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { EuiText, EuiCheckboxGroup, EuiSpacer, useGeneratedHtmlId } from '@elastic/eui';
import React, { Fragment } from 'react';
import { EuiText, EuiCheckboxGroup, EuiSpacer, useGeneratedHtmlId } from '@elastic/eui';

import {
StorybookMock as TabbedModalStorybookMock,
type Params as TabbedModalStorybookParams,
} from '../storybook/setup';

import { TabbedModal } from './tabbed_modal';
import { IModalTabDeclaration, IModalTabState } from './context';
import { TabbedModal, type IModalTabDeclaration } from './tabbed_modal';

export default {
title: 'Modal/Tabbed Modal',
Expand Down Expand Up @@ -49,9 +47,8 @@ export const TrivialExample = (params: TabbedModalStorybookParams) => {
},
modalActionBtn: {
id: 'wave',
label: 'Say Hi πŸ‘‹πŸΎ',
dataTestSubj: 'wave',
formattedMessageId: 'non.existent.id.for.helloWorld.example',
defaultMessage: 'Say Hi πŸ‘‹πŸΎ',
handler: ({ state }) => {
alert(state.message);
},
Expand Down Expand Up @@ -102,7 +99,7 @@ export const NonTrivialExample = (params: TabbedModalStorybookParams) => {
SelectOption,
}

interface IPizzaSelectorTabState extends IModalTabState {
interface IPizzaSelectorTabState {
checkboxIdToSelectedMap: Record<string, boolean>;
}

Expand Down Expand Up @@ -160,15 +157,13 @@ export const NonTrivialExample = (params: TabbedModalStorybookParams) => {
modalActionBtn: {
id: 'pizza',
dataTestSubj: 'order-pizza',
formattedMessageId: 'non.existent.id.for.orderPizza.example',
defaultMessage: 'Order πŸ•',
label: 'Order πŸ•',
handler: ({ state }) => {
alert(JSON.stringify(state));
},
},
};

// TODO: fix type mismatch
return (
<TabbedModal
{...params}
Expand Down

0 comments on commit 93706d5

Please sign in to comment.