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..202a81123c3ba --- /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 Dispatch, +} from 'react'; +import { type EuiTabProps, type CommonProps } from '@elastic/eui'; + +interface IDispatchAction { + type: string; + payload: any; +} + +export type IModalTabState = Record; + +// 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: { + state: S; + dispatch: Dispatch; +}) => ReactElement; + +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 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, 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 type IModalContextProviderProps>> = + PropsWithChildren<{ + tabs: Tabs; + selectedTabId: Tabs[number]['id']; + }>; + +export function ModalContextProvider>>({ + tabs, + selectedTabId, + children, +}: IModalContextProviderProps) { + const modalTabDefinitions = useRef([]); + + const initialModalState = useRef({ + // instantiate state with default meta information + meta: { + selectedTabId, + }, + }); + + const reducersMap = useMemo( + () => + tabs.reduce((result, { id, reducer, initialState, ...rest }) => { + 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..8f81acab27303 --- /dev/null +++ b/packages/shared-ux/modal/tabbed/src/tabbed_modal.stories.tsx @@ -0,0 +1,175 @@ +/* + * 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 { EuiText, EuiCheckboxGroup, EuiSpacer, useGeneratedHtmlId } from '@elastic/eui'; +import React, { Fragment } from 'react'; + +import { + StorybookMock as TabbedModalStorybookMock, + type Params as TabbedModalStorybookParams, +} from './storybook/setup'; + +import { TabbedModal } from './tabbed_modal'; +import { IModalTabDeclaration } from './context'; + +export default { + title: 'Modal/Tabbed Modal', + description: 'A controlled modal component that renders tabs', +}; + +const mock = new TabbedModalStorybookMock(); +const argTypes = mock.getArgumentTypes(); + +export const TrivialExample = (params: TabbedModalStorybookParams) => { + return ( + { + return ( + + + +

Click the button to send a message into the void

+
+
+ ); + }, + initialState: { + message: 'Hello World!!', + }, + modalActionBtn: { + label: 'Say Hi 👋🏾', + handler: ({ state }) => { + alert(state.message); + }, + }, + }, + ]} + selectedTabId="hello" + onClose={() => {}} + /> + ); +}; + +TrivialExample.argTypes = argTypes; + +export const NonTrivialExample = (params: TabbedModalStorybookParams) => { + 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, + }, + ]; + + enum ACTION_TYPES { + SelectOption, + } + + const pizzaSelector: IModalTabDeclaration<{ + checkboxIdToSelectedMap: Record; + }> = { + id: 'order', + title: 'Pizza of choice', + initialState: { + checkboxIdToSelectedMap: { + [checkboxGroupItemId2]: true, + }, + }, + reducer(state, action) { + switch (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: String(ACTION_TYPES.SelectOption), + payload: newCheckboxIdToSelectedMap, + }); + }; + + return ( + + + +

Select a Pizza (or more)

+
+ + onChange(id)} + /> +
+ ); + }, + modalActionBtn: { + label: 'Order 🍕', + handler: ({ state }) => { + alert(JSON.stringify(state)); + }, + }, + }; + + // TODO: fix type mismatch + return ( + {}} + modalTitle="Non trivial example" + tabs={[pizzaSelector]} + selectedTabId="order" + /> + ); +}; + +NonTrivialExample.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..b87c80fb53bd1 --- /dev/null +++ b/packages/shared-ux/modal/tabbed/src/tabbed_modal.tsx @@ -0,0 +1,106 @@ +/* + * 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 FC, useCallback } from 'react'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiTabs, + EuiTab, +} from '@elastic/eui'; +import { + ModalContextProvider, + useModalContext, + type IModalTabState, + type IModalTabDeclaration, + type IModalContextProviderProps, +} from './context'; + +interface ITabbedModalInner extends Pick, 'onClose'> { + modalTitle?: string; +} + +const TabbedModalInner: FC = ({ onClose, modalTitle }) => { + const { tabs, state, dispatch } = useModalContext(); + + const selectedTabId = state.meta.selectedTabId; + const selectedTabState = useMemo( + () => (selectedTabId ? state[selectedTabId] : {}), + [selectedTabId, state] + ); + + 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} + + )); + }; + + const btnClickHandler = useCallback(() => { + handler({ state: selectedTabState }); + }, [handler, selectedTabState]); + + return ( + + {Boolean(modalTitle) ? ( + + {modalTitle} + + ) : null} + + + {renderTabs()} + {React.createElement(SelectedTabContent, { + state: selectedTabState, + dispatch, + })} + + + + + {label} + + + + ); +}; + +export function TabbedModal>>({ + tabs, + selectedTabId, + ...rest +}: Omit, 'children'> & ITabbedModalInner) { + 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 ""