diff --git a/README.md b/README.md index 1fe76be..89488e0 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,16 @@ The module can also change what room/user/entity the user is looking at, and joi From the `RuntimeModule` instance, modules can listen for `WrapperLifecycle.Wrapper` to provide a wrapper react component. It would wrap the `MatrixChat` component and let any consumer add a header, a footer. +### Custom components + +From the `RuntimeModule` instance, modules can listen for different `CustomComponentLifecycle` events and swap the component +with a custom written component. In principle it works the same way as the `WrapperLifecycle`, but the usecase is different. +Instead of wrapping the element, you can intercept it, consume the state of the component including its children, and return +your own customly written component. + +It is possible to add `matrix-react-sdk`, `matrix-js-sdk` as a dependency into your custom module implementation to gain complete +functionality within your custom components, aswell as the ability to reuse sub-components and styles. + ## Contributing / developing Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for the mechanics of the contribution process. diff --git a/src/lifecycles/CustomComponentLifecycle.ts b/src/lifecycles/CustomComponentLifecycle.ts new file mode 100644 index 0000000..f33f479 --- /dev/null +++ b/src/lifecycles/CustomComponentLifecycle.ts @@ -0,0 +1,68 @@ +/* +Copyright 2024 Verji Tech AS +Copyright 2023 Mikhail Aheichyk +Copyright 2023 Nordeck IT + Consulting GmbH. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +CustomComponentLifecycle is heavily inspired by the WrapperLifecycle.ts, but is intended for a different usecase. +Instead of appending something within the wrapper, this lifecycle should swap the contents of the wrapper with a custom component provided by a module implementation. +*/ + +import { ComponentType, PropsWithChildren } from "react"; + +/** + * UserMenu lifecycle events + */ +export enum CustomComponentLifecycle { + /** + * An event to request the component module. `ModuleRunner` should invoke an event matching the wrapped component. + * So that any custom module can get the correct component based on Lifecycle event. + */ + AppsDrawer = "apps_drawer", + EntityTile = "entity_tile", + ErrorBoundary = "error_boundary", + Experimental = "experimental", + HelpUserSettingsTab = "help_user_settings_tab", + LegacyRoomHeader = "legacy_room_header", + LeftPanel = "left_panel", + LoggedInView = "logged_in_view", + MatrixChat = "matrix_chat", + MemberTile = "member_tile", + MessageContextMenu = "message_context_menu", + ReactionsRow = "reactions_row", + ReactionsRowButtonTooltip = "reactions_row_button_tooltip", + RolesRoomSettingsTab = "roles_room_settings_tab", + RoomHeader = "room_header", + RoomView = "room_view", + SessionManagerTab = "session_manage_tab", + SpacePanel = "space_panel", + UserMenu = "user_menu", +} + +/** + * Opts object that is populated with a Wrapper. + */ +export type CustomComponentOpts = { + /** + * A Wrapper React Component to be rendered around a component to swap. i.e the component to override. + */ + CustomComponent: ComponentType>; +}; + +/** + * Helper type that documents how to implement a CustomComponent listener. + */ +export type CustomComponentListener = (opts: CustomComponentOpts) => void; diff --git a/src/lifecycles/types.ts b/src/lifecycles/types.ts index 8bcded1..51ece61 100644 --- a/src/lifecycles/types.ts +++ b/src/lifecycles/types.ts @@ -17,5 +17,6 @@ limitations under the License. import { RoomViewLifecycle } from "./RoomViewLifecycle"; import { WidgetLifecycle } from "./WidgetLifecycle"; import { WrapperLifecycle } from "./WrapperLifecycle"; +import { CustomComponentLifecycle } from "./CustomComponentLifecycle"; -export type AnyLifecycle = RoomViewLifecycle | WidgetLifecycle | WrapperLifecycle; +export type AnyLifecycle = RoomViewLifecycle | WidgetLifecycle | WrapperLifecycle | CustomComponentLifecycle; diff --git a/test/lifecycles/CustomComponentLifecycle.test.tsx b/test/lifecycles/CustomComponentLifecycle.test.tsx new file mode 100644 index 0000000..49866a3 --- /dev/null +++ b/test/lifecycles/CustomComponentLifecycle.test.tsx @@ -0,0 +1,253 @@ +/* +Copyright 2024 Verji Tech AS + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ReactPortal } from "react"; +import { render, screen } from "@testing-library/react"; +import { RuntimeModule } from "../../src/RuntimeModule"; +import { + CustomComponentLifecycle, + CustomComponentListener, + CustomComponentOpts, +} from "../../src/lifecycles/CustomComponentLifecycle"; +import { ModuleApi } from "../../src/ModuleApi"; + +//Mock a CustomUserMenu +class CustomUserMenu extends React.Component { + render() { + return ( +
+ {this.props.children} +
+ ); + } +} +//Mock a default UserMenu +class UserMenu extends React.Component { + render() { + return ( +
+ {this.props.children} +
+ ); + } +} +interface MyComponentProps { + children: React.ReactNode; +} + +// Mock a functional custom Component +const CustomFunctionalComponent: React.FC = ({ children }) => { + return
{children}
; +}; +// Mock a functional Component +const FunctionalComponent: React.FC = ({ children }) => { + return
{children}
; +}; + +describe("CustomComponentLifecycle", () => { + describe("tests on Class components", () => { + let module: RuntimeModule; + let moduleApi: ModuleApi; + beforeAll(() => { + module = new (class extends RuntimeModule { + constructor() { + super(moduleApi); + + this.on(CustomComponentLifecycle.UserMenu, this.customComponentListener); + } + + protected customComponentListener: CustomComponentListener = ( + customComponentOpts: CustomComponentOpts, + ) => { + customComponentOpts.CustomComponent = ({ children }) => { + const usermenu = React.Children.toArray(children)[0] as UserMenu; + const usermenuChildren = usermenu?.props?.children ?? React.Fragment; + + return ( + <> + {usermenuChildren} + + ); + }; + }; + })(); + }); + + it("should swap the UserMenu with CustomUserMenu and keep it's children", () => { + const customComponentOpts: CustomComponentOpts = { CustomComponent: React.Fragment }; + module.emit(CustomComponentLifecycle.UserMenu, customComponentOpts); + + render( + + + Child1 + Child2 + + , + ); + + const customUserMenu = screen.getByTitle("CustomUserMenu"); + expect(customUserMenu).toBeInTheDocument(); + expect(customUserMenu.children.length).toEqual(2); + + const defaultUserMenu = screen.queryByTitle("UserMenu"); + expect(defaultUserMenu).toStrictEqual(null); + + const child1 = screen.getByText(/Child1/i); + const child2 = screen.getByText(/Child2/i); + expect(child1).toBeInTheDocument(); + expect(child2).toBeInTheDocument(); + }); + + it("should NOT swap the UserMenu when module emits an event we are not listening to in the module", () => { + const customComponentOpts: CustomComponentOpts = { CustomComponent: React.Fragment }; + + // We emit a different lifecycle event than what our mock-module is listening to + module.emit(CustomComponentLifecycle.Experimental, customComponentOpts); + + render( + + + Child1 + Child2 + + , + ); + + // The document should not be affected at all + const defaultUserMenu = screen.getByTitle("UserMenu"); + expect(defaultUserMenu).toBeInTheDocument(); + expect(defaultUserMenu.children.length).toEqual(2); + + const customUserMenu = screen.queryByTitle("CustomUserMenu"); + expect(customUserMenu).toStrictEqual(null); + + const child1 = screen.getByText(/Child1/i); + const child2 = screen.getByText(/Child2/i); + expect(child1).toBeInTheDocument(); + expect(child2).toBeInTheDocument(); + }); + }); + describe("tests on FC components", () => { + let module: RuntimeModule; + let moduleApi: ModuleApi; + beforeAll(() => { + module = new (class extends RuntimeModule { + constructor() { + super(moduleApi); + // In this case we are reacting to the "Experimental" lifecyle, because we are testing a generic mocked functional component. + this.on(CustomComponentLifecycle.Experimental, this.customComponentListener); + } + + protected customComponentListener: CustomComponentListener = ( + customComponentOpts: CustomComponentOpts, + ) => { + customComponentOpts.CustomComponent = ({ children }) => { + // We extract the component wrapped in CustomComponentOpts.CustomComponent + const defaultFunctionalComponent: ReactPortal = React.Children.toArray( + children, + )[0] as ReactPortal; + return ( + <> + + {defaultFunctionalComponent.props.children} + + + ); + }; + }; + })(); + }); + + it("should swap the the wrapped FunctionalComponent with the CustomFunctionalComponent and keep it's children", () => { + const customComponentOpts: CustomComponentOpts = { CustomComponent: React.Fragment }; + module.emit(CustomComponentLifecycle.Experimental, customComponentOpts); + + render( + + + Child1 + Child2 + + , + ); + + const CustomFunctionalComponent = screen.getByTitle("CustomFunctionalComponent"); + + expect(CustomFunctionalComponent).toBeInTheDocument(); + expect(CustomFunctionalComponent.children.length).toEqual(2); + + const defaultFunctionalCompoonent = screen.queryByTitle("FunctionalComponent"); + expect(defaultFunctionalCompoonent).toStrictEqual(null); + + const child1 = screen.getByText(/Child1/i); + const child2 = screen.getByText(/Child2/i); + expect(child1).toBeInTheDocument(); + expect(child2).toBeInTheDocument(); + }); + it("should swap the the wrapped FunctionalComponent(alternative rendering style) with the CustomFunctionalComponent", () => { + const customComponentOpts: CustomComponentOpts = { CustomComponent: React.Fragment }; + module.emit(CustomComponentLifecycle.Experimental, customComponentOpts); + + const firstChild = Child1; + const secondChild = Child2; + + render( + + {FunctionalComponent({ children: [firstChild, secondChild] })} + , + ); + const CustomFunctionalComponent = screen.getByTitle("CustomFunctionalComponent"); + expect(CustomFunctionalComponent).toBeInTheDocument(); + + const defaultFunctionalCompoonent = screen.queryByTitle("FunctionalComponent"); + expect(defaultFunctionalCompoonent).toStrictEqual(null); + + const child1 = screen.getByText(/Child1/i); + const child2 = screen.getByText(/Child2/i); + expect(child1).toBeInTheDocument(); + expect(child2).toBeInTheDocument(); + }); + it("should NOT swap the FucntionalComponent when module emits an event we are not listening to in the module", () => { + const customComponentOpts: CustomComponentOpts = { CustomComponent: React.Fragment }; + + // We emit a different lifecycle event than what our mock-module is listening to + module.emit(CustomComponentLifecycle.ErrorBoundary, customComponentOpts); + + render( + + + Child1 + Child2 + + , + ); + + // The document should not be affected at all + const defaultFunctionalCompoonent = screen.getByTitle("FunctionalComponent"); + expect(defaultFunctionalCompoonent).toBeInTheDocument(); + expect(defaultFunctionalCompoonent.children.length).toEqual(2); + + const CustomFunctionalComponent = screen.queryByTitle("CustomFunctionalComponent"); + expect(CustomFunctionalComponent).toStrictEqual(null); + + const child1 = screen.getByText(/Child1/i); + const child2 = screen.getByText(/Child2/i); + expect(child1).toBeInTheDocument(); + expect(child2).toBeInTheDocument(); + }); + }); +});