From 3dc5dc295f820fa8bb3d38b744ffc5c213b676cb Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 7 Mar 2024 12:29:18 +0100 Subject: [PATCH 1/4] start on skeleton tab component --- .github/CODEOWNERS | 1 + package.json | 1 + .../{ tsconfig.json => tsconfig.json} | 0 packages/shared-ux/modal/tabbed/README.mdx | 79 +++++++++ .../tabbed/index.tsx} | 3 + packages/shared-ux/modal/tabbed/kibana.jsonc | 6 + packages/shared-ux/modal/tabbed/package.json | 6 + .../modal/tabbed/src/context/index.tsx | 152 ++++++++++++++++++ .../modal/tabbed/src/storybook/setup.ts | 52 ++++++ .../modal/tabbed/src/tabbed_modal.stories.tsx | 65 ++++++++ .../modal/tabbed/src/tabbed_modal.tsx | 102 ++++++++++++ packages/shared-ux/share_modal/mocks/index.ts | 10 -- .../share_modal/mocks/src/storybook.ts | 55 ------- .../shared-ux/share_modal/types/index.d.ts | 23 --- tsconfig.base.json | 2 + yarn.lock | 4 + 16 files changed, 473 insertions(+), 88 deletions(-) rename packages/kbn-share-modal/{ tsconfig.json => tsconfig.json} (100%) create mode 100644 packages/shared-ux/modal/tabbed/README.mdx rename packages/shared-ux/{share_modal/impl/src/share_modal.stories.tsx => modal/tabbed/index.tsx} (76%) create mode 100644 packages/shared-ux/modal/tabbed/kibana.jsonc create mode 100644 packages/shared-ux/modal/tabbed/package.json create mode 100644 packages/shared-ux/modal/tabbed/src/context/index.tsx create mode 100644 packages/shared-ux/modal/tabbed/src/storybook/setup.ts create mode 100644 packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx create mode 100644 packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx delete mode 100644 packages/shared-ux/share_modal/mocks/index.ts delete mode 100644 packages/shared-ux/share_modal/mocks/src/storybook.ts delete mode 100644 packages/shared-ux/share_modal/types/index.d.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8121ca6706baf..b17a6f0581daa 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -723,6 +723,7 @@ test/plugin_functional/plugins/session_notifications @elastic/kibana-core x-pack/plugins/session_view @elastic/kibana-cloud-security-posture packages/kbn-set-map @elastic/kibana-operations examples/share_examples @elastic/appex-sharedux +packages/kbn-share-modal @elastic/appex-sharedux src/plugins/share @elastic/appex-sharedux packages/kbn-shared-svg @elastic/obs-ux-infra_services-team packages/shared-ux/avatar/solution @elastic/appex-sharedux diff --git a/package.json b/package.json index 2aca3ef5b12fb..2a7bc713226ec 100644 --- a/package.json +++ b/package.json @@ -775,6 +775,7 @@ "@kbn/shared-ux-router-types": "link:packages/shared-ux/router/types", "@kbn/shared-ux-storybook-config": "link:packages/shared-ux/storybook/config", "@kbn/shared-ux-storybook-mock": "link:packages/shared-ux/storybook/mock", + "@kbn/shared-ux-tabbed-modal": "link:packages/shared-ux/modal/tabbed", "@kbn/shared-ux-utility": "link:packages/kbn-shared-ux-utility", "@kbn/slo-schema": "link:x-pack/packages/kbn-slo-schema", "@kbn/snapshot-restore-plugin": "link:x-pack/plugins/snapshot_restore", diff --git a/packages/kbn-share-modal/ tsconfig.json b/packages/kbn-share-modal/tsconfig.json similarity index 100% rename from packages/kbn-share-modal/ tsconfig.json rename to packages/kbn-share-modal/tsconfig.json diff --git a/packages/shared-ux/modal/tabbed/README.mdx b/packages/shared-ux/modal/tabbed/README.mdx new file mode 100644 index 0000000000000..827fff0e9135e --- /dev/null +++ b/packages/shared-ux/modal/tabbed/README.mdx @@ -0,0 +1,79 @@ +--- +id: sharedUX/Components/KibanaTabbedModal +slug: /shared-ux/components/tabbed-modal +title: Tabbed Modal +description: A wrapper around `EuiAvatar` tailored for use in Kibana solutions. +tags: ['shared-ux', 'component'] +date: 2024-03-07 +--- + +## Description + +A wrapper around `EuiModal` for displaying and managing tabs tailored for use in Kibana solutions. + +## Usage + +A fairly trival use case might look like this + +```tsx +const hello: IModalTabDeclaration<{ message: string }> = { + id: 'hello', + title: 'Say Hello', + initialState: { + message: 'Hello World!', + }, + content: ({ state }) => { + // we can access state here to impact renders + return

Click the button to trigger a message

; + }, + modalActionBtn: { + label: 'Click me!', + handler: ({ state }) => { + // state can also be accessed here if neccessary + alert(state.message); + }, + }, +}; + + +``` + +a slightly more complex use case, where we'd like changes from interactions within the rendered tab +content to propagate to the state might look similar to this. + +```tsx +const hello: IModalTabDeclaration<{ message: string }> = { + id: 'hello', + title: 'Say Hello', + initialState: { + message: 'Hello World!', + }, + reducer(state, action) { + switch(action.type) { + case 'CHANGE_MESSAGE': + return { + ...state, + message: action.payload + }; + default: + return State; + } + }, + content: ({ dispatch }) => { + useEffect(() => { + dispatch({ type: 'CHANGE_MESSAGE', payload: 'Hello from the other side!' }); + }, []); + // we can access state here to impact renders + return

Click the button to trigger a message

; + }, + modalActionBtn: { + label: 'Click me!', + handler: ({ state }) => { + // message displayed in alert here would be different from the value set in our initial state + alert(state.message); + }, + }, +}; + + +``` diff --git a/packages/shared-ux/share_modal/impl/src/share_modal.stories.tsx b/packages/shared-ux/modal/tabbed/index.tsx similarity index 76% rename from packages/shared-ux/share_modal/impl/src/share_modal.stories.tsx rename to packages/shared-ux/modal/tabbed/index.tsx index 5c2d5b68ae2e0..574cd435f68ed 100644 --- a/packages/shared-ux/share_modal/impl/src/share_modal.stories.tsx +++ b/packages/shared-ux/modal/tabbed/index.tsx @@ -5,3 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +export { TabbedModal } from './src/tabbed_modal'; +export type { IModalTabDeclaration } from './src/context'; diff --git a/packages/shared-ux/modal/tabbed/kibana.jsonc b/packages/shared-ux/modal/tabbed/kibana.jsonc new file mode 100644 index 0000000000000..4d6f0d69600c0 --- /dev/null +++ b/packages/shared-ux/modal/tabbed/kibana.jsonc @@ -0,0 +1,6 @@ +{ + "type": "shared-common", + "id": "@kbn/shared-ux-tabbed-modal", + "owner": "@elastic/appex-sharedux" + } + \ No newline at end of file diff --git a/packages/shared-ux/modal/tabbed/package.json b/packages/shared-ux/modal/tabbed/package.json new file mode 100644 index 0000000000000..73c69504f0c62 --- /dev/null +++ b/packages/shared-ux/modal/tabbed/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/shared-ux-tabbed-modal", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/shared-ux/modal/tabbed/src/context/index.tsx b/packages/shared-ux/modal/tabbed/src/context/index.tsx new file mode 100644 index 0000000000000..f551e12e9ea82 --- /dev/null +++ b/packages/shared-ux/modal/tabbed/src/context/index.tsx @@ -0,0 +1,152 @@ +/* + * 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, { + createContext, + useContext, + useReducer, + useMemo, + useRef, + useCallback, + type PropsWithChildren, + type ReactElement, + type ComponentProps, + type Dispatch, +} from 'react'; +import { EuiTab } from '@elastic/eui'; + +interface IDispatchAction { + type: string; + payload: unknown; +} + +type IReducer = (state: S, action: IDispatchAction) => S; + +interface IModalTabActionBtn { + label: string; + handler: (args: { state: S }) => void; +} + +export type IModalTabContent> = (props: { + state: S; + dispatch: Dispatch; +}) => ReactElement; + +export type IModalTabDeclaration> = ComponentProps< + typeof EuiTab +> & { + id: string; + title: string; + initialState?: Partial; + reducer?: IReducer; + content: IModalTabContent; + modalActionBtn: IModalTabActionBtn; +}; + +interface IModalMetaState { + selectedTabId: string | null; +} + +interface IModalContext { + tabs: Array>, 'reducer' | 'initialState'>>; + state: { meta: IModalMetaState } & Record>; + dispatch: Dispatch; +} + +const ModalContext = createContext({ + tabs: [], + state: { + meta: { + selectedTabId: null, + }, + }, + dispatch: () => {}, +}); + +/** + * @description defines state transition for meta information to manage the modal, new meta action types + * must be prefixed with the string 'META_' + */ +const modalMetaReducer: IReducer = (state, action) => { + switch (action.type) { + case 'META_selectedTabId': + return { + ...state, + selectedTabId: action.payload as string, + }; + default: + return state; + } +}; + +export function ModalContextProvider< + T extends Array>> +>({ + tabs, + selectedTabId, + children, +}: PropsWithChildren<{ + tabs: T; + selectedTabId: T[number]['id']; +}>) { + const modalTabDefinitions = useRef([]); + + const initialModalState = useRef({ + // instantiate state with default meta information + meta: { + selectedTabId, + }, + }); + + const reducersMap = useMemo( + () => + tabs.reduce((result, { id, reducer, initialState, ...rest }) => { + // TODO: verify that re-renders don't make this value stale + initialModalState.current[id] = initialState ?? {}; + modalTabDefinitions.current.push({ id, ...rest }); + result[id] = reducer; + return result; + }, {}), + [tabs] + ); + + const combineReducers = useCallback( + function (reducers: Record>>) { + return (state: IModalContext['state'], action: IDispatchAction) => { + const newState = { ...state }; + + if (/^meta_/i.test(action.type)) { + newState.meta = modalMetaReducer(newState.meta, action); + } else { + newState[selectedTabId] = reducers[selectedTabId](newState[selectedTabId], action); + } + + return newState; + }; + }, + [selectedTabId] + ); + + const createInitialState = useCallback((state: IModalContext['state']) => { + return state; + }, []); + + const [state, dispatch] = useReducer( + combineReducers(reducersMap), + initialModalState.current, + createInitialState + ); + + return ( + + {children} + + ); +} + +export const useModalContext = () => useContext(ModalContext); diff --git a/packages/shared-ux/modal/tabbed/src/storybook/setup.ts b/packages/shared-ux/modal/tabbed/src/storybook/setup.ts new file mode 100644 index 0000000000000..04f50dc9345bb --- /dev/null +++ b/packages/shared-ux/modal/tabbed/src/storybook/setup.ts @@ -0,0 +1,52 @@ +/* + * 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 type { ComponentProps } from 'react'; +import { AbstractStorybookMock } from '@kbn/shared-ux-storybook-mock'; + +import TabbedModal from '../..'; + +type TabbedModalProps = ComponentProps; +type TabbedModalServiceArguments = Record; + +type Arguments = TabbedModalProps & TabbedModalServiceArguments; + +/** + * Storybook parameters provided from the controls addon. + */ +export type Params = Record; + +export class StorybookMock extends AbstractStorybookMock< + TabbedModalProps, + TabbedModalServiceArguments, + TabbedModalProps, + TabbedModalServiceArguments +> { + propArguments = { + tabs: { + control: { + type: 'array', + }, + defaultValue: [], + }, + }; + + serviceArguments = {}; + + dependencies = []; + + getProps(params?: Params): TabbedModalProps { + return { + tabs: this.getArgumentValue('tabs', params), + }; + } + + getServices(params: Params): TabbedModalServiceArguments { + return {}; + } +} diff --git a/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx b/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx new file mode 100644 index 0000000000000..bb4022d7ead6a --- /dev/null +++ b/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx @@ -0,0 +1,65 @@ +/* + * 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 from 'react'; + +import { + StorybookMock as TabbedModalStorybookMock, + type Params as TabbedModalStorybookParams, +} from './storybook/setup'; + +import { TabbedModal } from './tabbed_modal'; + +export default { + title: 'Modal/Tabbed Modal', + description: 'A controlled modal component that renders tabs', +}; + +const mock = new TabbedModalStorybookMock(); +const argTypes = mock.getArgumentTypes(); + +export const Modal = (params: TabbedModalStorybookParams) => { + return ( + { + return

Hello World!!

; + }, + initialState: { + age: 42, + }, + modalActionBtn: { + label: 'fire 🔥', + handler: ({ state }) => { + alert(JSON.stringify(state)); + }, + }, + }, + { + id: 'pdf', + title: 'PDF', + content: ({ state }) => { + return

PDF!!!

; + }, + modalActionBtn: { + label: 'print 🖨️', + handler: () => alert('printing...'), + }, + }, + ]} + selectedTabId="" + /> + ); +}; + +Modal.argTypes = argTypes; diff --git a/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx b/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx new file mode 100644 index 0000000000000..b39cc2900cb9c --- /dev/null +++ b/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx @@ -0,0 +1,102 @@ +/* + * 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, { + useMemo, + Fragment, + type ComponentProps, + type PropsWithChildren, + type FC, +} from 'react'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiTabs, + EuiTab, +} from '@elastic/eui'; +import { ModalContextProvider, useModalContext } from './context'; + +interface ITabbedModal extends Pick, 'onClose'> { + modalTitle?: string; +} + +const TabbedModalInner: FC = ({ onClose, modalTitle }) => { + const { tabs, state, dispatch } = useModalContext(); + + const selectedTabId = state.meta.selectedTabId; + const selectedTabState = selectedTabId ? state[selectedTabId] : {}; + + const { + content: SelectedTabContent, + modalActionBtn: { label, handler }, + } = useMemo(() => { + return tabs.find((obj) => obj.id === selectedTabId)!; + }, [selectedTabId, tabs]); + + const onSelectedTabChanged = (id: string) => { + dispatch({ type: 'META_selectedTabId', payload: id }); + }; + + const renderTabs = () => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + prepend={tab.prepend} + append={tab.append} + > + {tab.title} + + )); + }; + + return ( + + {Boolean(modalTitle) ? ( + + {modalTitle} + + ) : null} + + + {renderTabs()} + {React.createElement(SelectedTabContent, { + state: selectedTabState, + dispatch, + })} + + + + + {label} + + + + ); +}; + +export const TabbedModal = ({ + tabs, + selectedTabId, + ...rest +}: PropsWithChildren< + Omit, 'children'> & ITabbedModal +>) => { + return ( + + + + ); +}; diff --git a/packages/shared-ux/share_modal/mocks/index.ts b/packages/shared-ux/share_modal/mocks/index.ts deleted file mode 100644 index dd836d1d0ed57..0000000000000 --- a/packages/shared-ux/share_modal/mocks/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * 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. - */ - -export { StorybookMock as ShareModalStorybookMock } from './src/storybook'; -export type { Params as ShareModalStorybookParams } from './src/storybook'; diff --git a/packages/shared-ux/share_modal/mocks/src/storybook.ts b/packages/shared-ux/share_modal/mocks/src/storybook.ts deleted file mode 100644 index 489bc8e3d10e7..0000000000000 --- a/packages/shared-ux/share_modal/mocks/src/storybook.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 { AbstractStorybookMock, ArgumentParams } from '@kbn/shared-ux-storybook-mock'; -import { ModalProps as ShareModalProps } from '@kbn/share-modal'; - -import { ArgTypes } from '@storybook/react'; -import { ShareModalStorybookMock } from '..'; - -type PropArguments = Pick; -type Arguments = PropArguments; - -/** - * Storybook parameters provided from the controls addon. - */ -export type Params = Record; - -const redirectMock = new ShareModalStorybookMock(); - -export class StorybookMock extends AbstractStorybookMock { - serviceArguments: ArgTypes<{}>; - getServices(params?: ArgumentParams | undefined): {} { - throw new Error('Method not implemented.'); - } - propArguments = { - objectType: { - control: { - type: 'text', - }, - defaultValue: '', - }, - modalBodyDescriptions: { - control: { - type: 'text', - }, - defaultValue: '', - }, - tabs: {}, - }; - - dependencies = []; - - getProps(params?: Params): ShareModalProps { - return { - modalBodyDescriptions: this.getArgumentValue('description', params), - objectType: this.getArgumentValue('objectType', params), - tabs: this.getArgumentValue('tabs', params), - }; - } -} diff --git a/packages/shared-ux/share_modal/types/index.d.ts b/packages/shared-ux/share_modal/types/index.d.ts deleted file mode 100644 index 427989f9e90c4..0000000000000 --- a/packages/shared-ux/share_modal/types/index.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 { ModalProps } from '@kbn/share-modal'; -import { ReactElement } from 'react'; - -/** - * Props for the `ShareModal` pure component. - */ -export type ShareModalComponentProps = Partial< - Pick -> & { - objectType: string; - modalBodyDescription: string; - tabs: Array<{ id: string; name: string; content: ReactElement }>; -}; - -export type ShareModalProps = ShareModalComponentProps; diff --git a/tsconfig.base.json b/tsconfig.base.json index 8ee230dfa6dba..79f6bbb627c3f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1540,6 +1540,8 @@ "@kbn/shared-ux-storybook-config/*": ["packages/shared-ux/storybook/config/*"], "@kbn/shared-ux-storybook-mock": ["packages/shared-ux/storybook/mock"], "@kbn/shared-ux-storybook-mock/*": ["packages/shared-ux/storybook/mock/*"], + "@kbn/shared-ux-tabbed-modal": ["packages/shared-ux/modal/tabbed"], + "@kbn/shared-ux-tabbed-modal/*": ["packages/shared-ux/modal/tabbed/*"], "@kbn/shared-ux-utility": ["packages/kbn-shared-ux-utility"], "@kbn/shared-ux-utility/*": ["packages/kbn-shared-ux-utility/*"], "@kbn/slo-schema": ["x-pack/packages/kbn-slo-schema"], diff --git a/yarn.lock b/yarn.lock index 36a3408b03a1a..83d191f0b0fbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6148,6 +6148,10 @@ version "0.0.0" uid "" +"@kbn/shared-ux-tabbed-modal@link:packages/shared-ux/modal/tabbed": + version "0.0.0" + uid "" + "@kbn/shared-ux-utility@link:packages/kbn-shared-ux-utility": version "0.0.0" uid "" From afcbcf7884030dc144628c184f3797e19b9a29d6 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 7 Mar 2024 16:18:47 +0100 Subject: [PATCH 2/4] fix issue with stale state value in btn action handler --- .../shared-ux/modal/tabbed/src/context/index.tsx | 1 - packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx | 12 ++++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/shared-ux/modal/tabbed/src/context/index.tsx b/packages/shared-ux/modal/tabbed/src/context/index.tsx index f551e12e9ea82..1d2c32d38ff91 100644 --- a/packages/shared-ux/modal/tabbed/src/context/index.tsx +++ b/packages/shared-ux/modal/tabbed/src/context/index.tsx @@ -106,7 +106,6 @@ export function ModalContextProvider< const reducersMap = useMemo( () => tabs.reduce((result, { id, reducer, initialState, ...rest }) => { - // TODO: verify that re-renders don't make this value stale initialModalState.current[id] = initialState ?? {}; modalTabDefinitions.current.push({ id, ...rest }); result[id] = reducer; diff --git a/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx b/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx index b39cc2900cb9c..4b53e97523853 100644 --- a/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx +++ b/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx @@ -12,6 +12,7 @@ import React, { type ComponentProps, type PropsWithChildren, type FC, + useCallback, } from 'react'; import { EuiButton, @@ -33,7 +34,10 @@ const TabbedModalInner: FC = ({ onClose, modalTitle }) => { const { tabs, state, dispatch } = useModalContext(); const selectedTabId = state.meta.selectedTabId; - const selectedTabState = selectedTabId ? state[selectedTabId] : {}; + const selectedTabState = useMemo( + () => (selectedTabId ? state[selectedTabId] : {}), + [selectedTabId, state] + ); const { content: SelectedTabContent, @@ -62,6 +66,10 @@ const TabbedModalInner: FC = ({ onClose, modalTitle }) => { )); }; + const btnClickHandler = useCallback(() => { + handler({ state: selectedTabState }); + }, [handler, selectedTabState]); + return ( {Boolean(modalTitle) ? ( @@ -79,7 +87,7 @@ const TabbedModalInner: FC = ({ onClose, modalTitle }) => { - + {label} From 10dd777dcdbda73f55bf185dd907aade5c250794 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 7 Mar 2024 16:19:28 +0100 Subject: [PATCH 3/4] improve usage examples --- .../modal/tabbed/src/tabbed_modal.stories.tsx | 142 +++++++++++++++--- 1 file changed, 121 insertions(+), 21 deletions(-) diff --git a/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx b/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx index bb4022d7ead6a..f899f818da9fb 100644 --- a/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx +++ b/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import React from 'react'; +import { EuiText, EuiCheckboxGroup, EuiSpacer, useGeneratedHtmlId } from '@elastic/eui'; +import React, { Fragment } from 'react'; import { StorybookMock as TabbedModalStorybookMock, @@ -23,43 +24,142 @@ export default { const mock = new TabbedModalStorybookMock(); const argTypes = mock.getArgumentTypes(); -export const Modal = (params: TabbedModalStorybookParams) => { +export const TrivialExample = (params: TabbedModalStorybookParams) => { return ( { - return

Hello World!!

; + content: () => { + return ( + +

Click the button to shout a message into the void

+
+ ); }, initialState: { - age: 42, + message: 'Hello World!!', }, modalActionBtn: { - label: 'fire 🔥', + label: 'Say Hi 👋🏾', handler: ({ state }) => { - alert(JSON.stringify(state)); + alert(state.message); }, }, }, - { - id: 'pdf', - title: 'PDF', - content: ({ state }) => { - return

PDF!!!

; - }, - modalActionBtn: { - label: 'print 🖨️', - handler: () => alert('printing...'), - }, - }, ]} - selectedTabId="" + selectedTabId="hello" + onClose={() => {}} + /> + ); +}; + +TrivialExample.argTypes = argTypes; + +export const NonTrivialExample = (params: TabbedModalStorybookParams) => { + enum ACTION_TYPES { + SelectOption, + } + + const checkboxGroupItemId1 = useGeneratedHtmlId({ + prefix: 'checkboxGroupItem', + suffix: 'first', + }); + const checkboxGroupItemId2 = useGeneratedHtmlId({ + prefix: 'checkboxGroupItem', + suffix: 'second', + }); + const checkboxGroupItemId3 = useGeneratedHtmlId({ + prefix: 'checkboxGroupItem', + suffix: 'third', + }); + + const checkboxes = [ + { + id: checkboxGroupItemId1, + label: 'Margherita', + 'data-test-sub': 'dts_test', + }, + { + id: checkboxGroupItemId2, + label: 'Diavola', + className: 'classNameTest', + }, + { + id: checkboxGroupItemId3, + label: 'Hawaiian Pizza', + disabled: true, + }, + ]; + + const pizzaSelector = { + id: 'order', + title: 'Pizza of choice', + initialState: { + checkboxIdToSelectedMap: { + [checkboxGroupItemId2]: true, + }, + }, + reducer(state, action) { + switch (String(action.type)) { + case String(ACTION_TYPES.SelectOption): + return { + ...state, + checkboxIdToSelectedMap: action.payload, + }; + default: + return state; + } + }, + content: ({ state, dispatch }) => { + const { checkboxIdToSelectedMap } = state; + + const onChange = (optionId) => { + const newCheckboxIdToSelectedMap = { + ...checkboxIdToSelectedMap, + ...{ + [optionId]: !checkboxIdToSelectedMap[optionId], + }, + }; + + dispatch({ type: ACTION_TYPES.SelectOption, payload: newCheckboxIdToSelectedMap }); + }; + + return ( + + + +

Select a Pizza (or more)

+
+ + onChange(id)} + /> +
+ ); + }, + modalActionBtn: { + label: 'Order 🍕', + handler: ({ state }) => { + alert(JSON.stringify(state)); + }, + }, + }; + + return ( + {}} + modalTitle="Non trivial example" + tabs={[pizzaSelector]} + selectedTabId="order" /> ); }; -Modal.argTypes = argTypes; +NonTrivialExample.argTypes = argTypes; From d7fa09feb838552ed83ee607bede9a6fc97b0b6a Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 7 Mar 2024 20:03:43 +0100 Subject: [PATCH 4/4] cleanup typing --- .../modal/tabbed/src/context/index.tsx | 57 ++++++++++--------- .../modal/tabbed/src/tabbed_modal.stories.tsx | 30 ++++++---- .../modal/tabbed/src/tabbed_modal.tsx | 30 +++++----- 3 files changed, 62 insertions(+), 55 deletions(-) diff --git a/packages/shared-ux/modal/tabbed/src/context/index.tsx b/packages/shared-ux/modal/tabbed/src/context/index.tsx index 1d2c32d38ff91..202a81123c3ba 100644 --- a/packages/shared-ux/modal/tabbed/src/context/index.tsx +++ b/packages/shared-ux/modal/tabbed/src/context/index.tsx @@ -15,46 +15,46 @@ import React, { useCallback, type PropsWithChildren, type ReactElement, - type ComponentProps, type Dispatch, } from 'react'; -import { EuiTab } from '@elastic/eui'; +import { type EuiTabProps, type CommonProps } from '@elastic/eui'; interface IDispatchAction { type: string; - payload: unknown; + payload: any; } -type IReducer = (state: S, action: IDispatchAction) => S; +export type IModalTabState = Record; -interface IModalTabActionBtn { - label: string; - handler: (args: { state: S }) => void; -} +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type IModalMetaState = { + selectedTabId: string | null; +}; + +type IReducer = (state: S, action: IDispatchAction) => S; -export type IModalTabContent> = (props: { +export type IModalTabContent = (props: { state: S; dispatch: Dispatch; }) => ReactElement; -export type IModalTabDeclaration> = ComponentProps< - typeof EuiTab -> & { +interface IModalTabActionBtn extends CommonProps { + label: string; + handler: (args: { state: S }) => void; +} + +export interface IModalTabDeclaration extends EuiTabProps { id: string; title: string; initialState?: Partial; reducer?: IReducer; content: IModalTabContent; modalActionBtn: IModalTabActionBtn; -}; - -interface IModalMetaState { - selectedTabId: string | null; } -interface IModalContext { - tabs: Array>, 'reducer' | 'initialState'>>; - state: { meta: IModalMetaState } & Record>; +interface IModalContext { + tabs: Array, 'reducer' | 'initialState'>>; + state: { meta: IModalMetaState } & Record; dispatch: Dispatch; } @@ -69,7 +69,7 @@ const ModalContext = createContext({ }); /** - * @description defines state transition for meta information to manage the modal, new meta action types + * @description defines state transition for meta information to manage the modal, meta action types * must be prefixed with the string 'META_' */ const modalMetaReducer: IReducer = (state, action) => { @@ -84,16 +84,17 @@ const modalMetaReducer: IReducer = (state, action) => { } }; -export function ModalContextProvider< - T extends Array>> ->({ +export type IModalContextProviderProps>> = + PropsWithChildren<{ + tabs: Tabs; + selectedTabId: Tabs[number]['id']; + }>; + +export function ModalContextProvider>>({ tabs, selectedTabId, children, -}: PropsWithChildren<{ - tabs: T; - selectedTabId: T[number]['id']; -}>) { +}: IModalContextProviderProps) { const modalTabDefinitions = useRef([]); const initialModalState = useRef({ @@ -115,7 +116,7 @@ export function ModalContextProvider< ); const combineReducers = useCallback( - function (reducers: Record>>) { + function (reducers: Record>) { return (state: IModalContext['state'], action: IDispatchAction) => { const newState = { ...state }; diff --git a/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx b/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx index f899f818da9fb..8f81acab27303 100644 --- a/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx +++ b/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx @@ -15,6 +15,7 @@ import { } from './storybook/setup'; import { TabbedModal } from './tabbed_modal'; +import { IModalTabDeclaration } from './context'; export default { title: 'Modal/Tabbed Modal', @@ -35,9 +36,12 @@ export const TrivialExample = (params: TabbedModalStorybookParams) => { title: 'Hello', content: () => { return ( - -

Click the button to shout a message into the void

-
+ + + +

Click the button to send a message into the void

+
+
); }, initialState: { @@ -60,10 +64,6 @@ export const TrivialExample = (params: TabbedModalStorybookParams) => { TrivialExample.argTypes = argTypes; export const NonTrivialExample = (params: TabbedModalStorybookParams) => { - enum ACTION_TYPES { - SelectOption, - } - const checkboxGroupItemId1 = useGeneratedHtmlId({ prefix: 'checkboxGroupItem', suffix: 'first', @@ -95,7 +95,13 @@ export const NonTrivialExample = (params: TabbedModalStorybookParams) => { }, ]; - const pizzaSelector = { + enum ACTION_TYPES { + SelectOption, + } + + const pizzaSelector: IModalTabDeclaration<{ + checkboxIdToSelectedMap: Record; + }> = { id: 'order', title: 'Pizza of choice', initialState: { @@ -104,7 +110,7 @@ export const NonTrivialExample = (params: TabbedModalStorybookParams) => { }, }, reducer(state, action) { - switch (String(action.type)) { + switch (action.type) { case String(ACTION_TYPES.SelectOption): return { ...state, @@ -125,7 +131,10 @@ export const NonTrivialExample = (params: TabbedModalStorybookParams) => { }, }; - dispatch({ type: ACTION_TYPES.SelectOption, payload: newCheckboxIdToSelectedMap }); + dispatch({ + type: String(ACTION_TYPES.SelectOption), + payload: newCheckboxIdToSelectedMap, + }); }; return ( @@ -151,6 +160,7 @@ export const NonTrivialExample = (params: TabbedModalStorybookParams) => { }, }; + // TODO: fix type mismatch return ( , 'onClose'> { +interface ITabbedModalInner extends Pick, 'onClose'> { modalTitle?: string; } -const TabbedModalInner: FC = ({ onClose, modalTitle }) => { +const TabbedModalInner: FC = ({ onClose, modalTitle }) => { const { tabs, state, dispatch } = useModalContext(); const selectedTabId = state.meta.selectedTabId; @@ -54,7 +53,6 @@ const TabbedModalInner: FC = ({ onClose, modalTitle }) => { return tabs.map((tab, index) => ( onSelectedTabChanged(tab.id)} isSelected={tab.id === selectedTabId} disabled={tab.disabled} @@ -95,16 +93,14 @@ const TabbedModalInner: FC = ({ onClose, modalTitle }) => { ); }; -export const TabbedModal = ({ +export function TabbedModal>>({ tabs, selectedTabId, ...rest -}: PropsWithChildren< - Omit, 'children'> & ITabbedModal ->) => { +}: Omit, 'children'> & ITabbedModalInner) { return ( ); -}; +}