Skip to content
This repository was archived by the owner on Jan 30, 2025. It is now read-only.

CustomComponentLifecycle - Swap/Override components in element-web or matrix-react-sdk using module-api and the CustomComponentLifecycle #36

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions src/lifecycles/CustomComponentLifecycle.ts
Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren<{}>>;
};

/**
* Helper type that documents how to implement a CustomComponent listener.
*/
export type CustomComponentListener = (opts: CustomComponentOpts) => void;
3 changes: 2 additions & 1 deletion src/lifecycles/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
253 changes: 253 additions & 0 deletions test/lifecycles/CustomComponentLifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="customUserMenu" title="CustomUserMenu">
{this.props.children}
</div>
);
}
}
//Mock a default UserMenu
class UserMenu extends React.Component {
render() {
return (
<div className="defaultUserMenu" title="UserMenu">
{this.props.children}
</div>
);
}
}
interface MyComponentProps {
children: React.ReactNode;
}

// Mock a functional custom Component
const CustomFunctionalComponent: React.FC<MyComponentProps> = ({ children }) => {
return <div title="CustomFunctionalComponent">{children}</div>;
};
// Mock a functional Component
const FunctionalComponent: React.FC<MyComponentProps> = ({ children }) => {
return <div title="FunctionalComponent">{children}</div>;
};

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 (
<>
<CustomUserMenu>{usermenuChildren}</CustomUserMenu>
</>
);
};
};
})();
});

it("should swap the UserMenu with CustomUserMenu and keep it's children", () => {
const customComponentOpts: CustomComponentOpts = { CustomComponent: React.Fragment };
module.emit(CustomComponentLifecycle.UserMenu, customComponentOpts);

render(
<customComponentOpts.CustomComponent>
<UserMenu>
<span>Child1</span>
<span>Child2</span>
</UserMenu>
</customComponentOpts.CustomComponent>,
);

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(
<customComponentOpts.CustomComponent>
<UserMenu>
<span>Child1</span>
<span>Child2</span>
</UserMenu>
</customComponentOpts.CustomComponent>,
);

// 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 (
<>
<CustomFunctionalComponent>
{defaultFunctionalComponent.props.children}
</CustomFunctionalComponent>
</>
);
};
};
})();
});

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(
<customComponentOpts.CustomComponent>
<FunctionalComponent>
<span>Child1</span>
<span>Child2</span>
</FunctionalComponent>
</customComponentOpts.CustomComponent>,
);

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 = <span key={1}>Child1</span>;
const secondChild = <span key={2}>Child2</span>;

render(
<customComponentOpts.CustomComponent>
{FunctionalComponent({ children: [firstChild, secondChild] })}
</customComponentOpts.CustomComponent>,
);
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(
<customComponentOpts.CustomComponent>
<FunctionalComponent>
<span>Child1</span>
<span>Child2</span>
</FunctionalComponent>
</customComponentOpts.CustomComponent>,
);

// 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();
});
});
});