Skip to content

Commit

Permalink
Make WidgetsManager extensible (#18)
Browse files Browse the repository at this point in the history
* add extends and contextProviderProps

* some renames
  • Loading branch information
mahmoudmoravej authored Dec 4, 2024
1 parent 67da97d commit c0df5ec
Show file tree
Hide file tree
Showing 20 changed files with 251 additions and 737 deletions.
6 changes: 6 additions & 0 deletions .changeset/weak-experts-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workleap/r2wc": minor
---

- Add `contextProviderProps` and `extends` method to `WidgetsManager` to make it more flexible and being able to expose functions outside of the Widgets.
- Rename the `appSettings` to `settings` in `IWidgetsManager` interface.
73 changes: 52 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ Widgets inside the same project could share context as a regular React app. This


```tsx
// src/AppContextProvider.tsx
export function AppContextProvider({ children }: {
// src/WidgetsContextProvider.tsx
export function WidgetsContextProvider({ children }: {
children?: React.ReactNode | undefined;
}) {
const [isResultOpen, setIsResultOpen] = useState(false);
Expand All @@ -131,23 +131,23 @@ export function AppContextProvider({ children }: {
> [!WARNING]
The above element is NOT being associated with any DOM element, and the `{children}` (i.e widgets) are being rendered in different DOM nodes (Thanks to React [createPortal](https://react.dev/reference/react-dom/createPortal)). So, if any of above providers generate DOM element, they are not being present in DOM hierarchy.

#### [Optional] App settings
#### [Optional] Widgets settings

There are scenarios where you want to pass down some app settings that are being used by all widgets. For example:
There are scenarios where you want to pass down some high level settings that are being used by all widgets. For example:

- Passing app current theme or language
- Passing backend API URL, app name, app logo, etc.

If you have only one widget, it is ok to pass them through it as widget props, but if you have multiple widgets, it is not perfect to do the same for all widgets. To do that, add these settings to `AppSettings` and modify previously created `AppContextProvider` to handle them.
If you have only one widget, it is ok to pass them through it as widget props, but if you have multiple widgets, it is not perfect to do the same for all widgets. To do that, add these settings to `WidgetsSettings` and modify previously created `WidgetsContextProvider` to handle them.

```tsx
// src/AppContextProvider.tsx
export interface AppSettings {
// src/WidgetsContextProvider.tsx
export interface WidgetsSettings {
theme: "light" | "dark" | "system";
language: string;
}

export function AppContextProvider({ children, ...props }: PropsWithChildren<AppSettings>) {
export function WidgetsContextProvider({ children, ...props }: PropsWithChildren<WidgetsSettings>) {
const [theme, setTheme] = useState(props.theme);

useEffect(() => {
Expand All @@ -163,10 +163,10 @@ export function AppContextProvider({ children, ...props }: PropsWithChildren<App
);
}
```
Pay attention to the `useEffect` in the above code. We need it if we wrap a setting with `useSate`. In this case, the passed value to `useState` is only for initiation and it is not getting updated on later calls. As the host app can change the app settings through the `update` method, we need to use `useEffect` to make sure the state gets the changes.
Pay attention to the `useEffect` in the above code. We need it if we wrap a setting with `useSate`. In this case, the passed value to `useState` is only for initiation and it is not getting updated on later calls. As the host app can change the widgets settings through the `update` method, we need to use `useEffect` to make sure the state gets the changes.

> [!NOTE]
You need to merge the two above examples if you support both optional "Sharing context" and passing down "App Settings" use cases.
You need to merge the two above examples if you support both optional "Sharing context" and passing down "Widgets Settings" use cases.


### Create Web Components
Expand Down Expand Up @@ -263,7 +263,8 @@ Put the created file inside the `src` folder.
The host app needs an API to register and initialize the widgets. `WidgetsManager` class does this for you. To do that, create the [widgets.ts](/packages/movie-widgets/src/widgets.ts) file and create a new instance of the `WidgetsManager` class. Its construcor accepts this parameters:

- `elements` (**required**): An array of widgets to register. Without having them registered, you cannot use them in the host app.
- `contextProvider`: to pass shared context provider, e.g. `AppContextProvider`.
- `contextProvider`: to pass shared context provider, e.g. `WidgetsContextProvider`.
- `contextProviderProps`: to pass initial context provider props. It is helpful when you want to initiate some props through build time, and not leaving it to `initialize` function. For example, when you want to inject event emitter and expose the function through the `extend` method. One popular example could be any type of refresh data methods.
- `ignoreLoadingCss`: if you want the host to load the related CSS file, set this to true. Otherwise the manager will load the css file [automatically](/packages/r2wc/src/WidgetsManager.tsx).
- `syncRendering`: (**not recommended**) If you want the web component rendering happens syncronously. It may be useful for critical widgets that need to be present as soon as the page gets loaded. It is not recommended as it uses [flushSync](https://react.dev/reference/react-dom/flushSync) behind the hood and it may affect the overal page load time.

Expand All @@ -275,7 +276,7 @@ If you like to have access to it across the whole document, you can assign it to
// src/widgets.ts
const MovieWidgets = new WidgetsManager({
elements: [MovieDetailsElement, MoviePopUpElement],
contextProvider: AppContextProvider
contextProvider: WidgetsContextProvider
});

window.MovieWidgets = MovieWidgets;
Expand All @@ -287,33 +288,65 @@ export { MovieWidgets };
> You need to create a `types.d.ts` [file](/packages/movie-widgets/src/types.d.ts) and add the following declaration to be able to define `window.MovieWidgets` at window level:
> ```ts
> import type { IWidgetsManager } from "@workleap/r2wc";
> import type { AppSettings } from "./AppContextProvider.tsx";
> import type { WidgetsSettings } from "./WidgetsContextProvider.tsx";
>
> export declare global {
> interface Window {
> MovieWidgets?: IWidgetsManager<AppSettings>;
> MovieWidgets?: IWidgetsManager<WidgetsSettings>;
> }
> }
> ```

If you don't have `contextProvider`, simply ignore it:
```tsx
// src/widgets.ts
window.MovieWidgets = new WidgetsManager({
const MovieWidgets = new WidgetsManager({
elements: [MovieDetailsElement, MoviePopUpElement]
});

window.MovieWidgets = MovieWidgets;

export { MovieWidgets };
```

`WidgetsManager` loades the related `CSS` file automatically at the time of load. If you want to load the CSS file manually, you can pass the `ignoreLoadingCss: true` to the constructor.

`WidgetsManager` class exposes the following API which is being used inside the host apps.
- `initialize(config: AppSettings)`: To initiate the widgets and pass the initial state of `AppSettings`.
- `update(config: Partial<AppSettings>)`: To change the state of `AppSettings`. You only need to pass the changed settings.
- `appSettings: AppSettings`: To get the current app settings.
- `initialize(settings: WidgetsSettings)`: To initiate the widgets and pass the initial state of `WidgetsSettings`.
- `update(settings: Partial<WidgetsSettings>)`: To change the state of `WidgetsSettings`. You only need to pass the changed settings.
- `settings: WidgetsSettings`: To get the current widgets settings.
- `unmount()`: To unmount the rendered elements. You can call `initialize` after to get a fresh rendering. It is mostly helpful in test environments (like Storybook) where you need to re-initialize the widgets without reloading the whole page. Note that this function doesn't remove the widgest tags from the page. It only removes the rendered content.

#### Extending WidgetsManager
To add custom functionalities to `WidgetsManager` you can simply use the `extends<T>(data: T)` function. All the passed data will be injected to the instanciated `WidgetsManager` and will be exposed through provided variable. In the following example, `window.MovieWidgets.refreshData()` is a valid and type-safe method.
```ts
export declare global {
interface Extensions {
refreshData: ()=> void;
}
interface Window {
MovieWidgets?: IWidgetsManager<WidgetsSettings> & Extensions;
}
}

const refrehHandler = new InvokeMethodHandler();

const MovieWidgets = new WidgetsManager({
elements: [MovieDetailsElement, MoviePopUpElement],
contextProvider: WidgetsContextProvider,
contextProviderProps: {
refreshHandler: refrehHandler
}
}).extends({
refreshData : refrehHandler.emit.bind(refrehHandler)
});

window.MovieWidgets = MovieWidgets;

export { MovieWidgets };
```


#### [Optional] Define React helpers
If you want to ease the process of using your defined web components inside React hosts, you
have to create the following files and [later](#optional-react-helpers-output) set up the package to export them properly.
Expand Down Expand Up @@ -526,7 +559,7 @@ import { MovieDetails, MovieFinder } from "@samples/movie-widgets/react";
> You can use regular HTML attributes, like `style`, with these components.

#### Initial script
This part is pretty similar to VanilaJS example. As we load this package from CDN, **NOT** as a package, we have to load it separately in `index.html` file:
This part is pretty similar to VanillaJS example. As we load this package from CDN, **NOT** as a package, we have to load it separately in `index.html` file:

```html
<html lang="en">
Expand All @@ -550,9 +583,7 @@ This part is pretty similar to VanilaJS example. As we load this package from CD
Even if the current POC is working, there are some improvements that we will look at in the future:

- Possibility of implementing the widget using the Shadow DOM to avoid conflicts with the host app styles.
- [x] Having styles [loaded inside shadow elements](https://github.com/gsoft-inc/wl-framework-agnostic-widgets-template/pull/10)
- [ ] Pushing all elements to render inside Shadow root. Currently Orbiter renders Modal and Menu at document level which causes them to get their styles from the main document, not the shadow root styles.
- Further optimizations for bundle size with improved tree-shaking
- Strategy to load some components dynamically to decrease the whole package size
- [Use SSR + Declarative Shadow Dom](https://web.dev/articles/declarative-shadow-dom) to boost performance and remove flickering at all

Expand Down
36 changes: 24 additions & 12 deletions packages/r2wc/src/WidgetsManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,28 +58,34 @@ export function notifyWidgetMountState(element: WebComponentHTMLElementBase, eve
}
}

interface IWidgetsManager<T> {
export interface IWidgetsManager<T> {
initialize: (settings?: T) => void;
update: (settings: Partial<T>) => void;
appSettings?: T | null;
settings?: T | null;
unmount: () => void;
}

interface ConstructionOptions<T> {
elements: WebComponentHTMLElementType[];
contextProvider?: ComponentType<T | (T & { children?: React.ReactNode })>;
contextProviderProps?: Partial<T>;
ignoreLoadingCss?: boolean;
syncRendering?: boolean;
}

export class WidgetsManager<AppSettings = unknown> implements IWidgetsManager<AppSettings> {
#contextProps: Observable<AppSettings> = new Observable();
export class WidgetsManager<WidgetsSettings = unknown> implements IWidgetsManager<WidgetsSettings> {
#contextProps: Observable<WidgetsSettings> = new Observable<WidgetsSettings>();
#initialContextProps: Partial<WidgetsSettings> | undefined;
static #instanciated = false;
#syncRendering: boolean;

constructor (
{ elements, contextProvider, ignoreLoadingCss = false, syncRendering = false }: ConstructionOptions<AppSettings>) {
settings: ConstructionOptions<WidgetsSettings>) {
const { elements, contextProvider, contextProviderProps, ignoreLoadingCss = false, syncRendering = false } = settings;

if (WidgetsManager.#instanciated) {throw new Error("You cannot create multiple instances of WidgetsManager");}

this.#initialContextProps = contextProviderProps;
WidgetsManager.#instanciated = true;

this.#syncRendering = syncRendering;
Expand All @@ -91,6 +97,12 @@ export class WidgetsManager<AppSettings = unknown> implements IWidgetsManager<Ap
};
}

extends<ExtendedProps extends object>(data: ExtendedProps): IWidgetsManager<WidgetsSettings> & ExtendedProps {
Object.assign(this, data);

return this as unknown as IWidgetsManager<WidgetsSettings> & ExtendedProps;
}

#countOccurrences(mainString: string, subString: string) {
return mainString.split(subString).length - 1;
}
Expand All @@ -114,13 +126,13 @@ export class WidgetsManager<AppSettings = unknown> implements IWidgetsManager<Ap
document.head.appendChild(link);
}

#renderContextWithProps(ContextProvider: ComponentType<AppSettings | (AppSettings & { children?: React.ReactNode })>, children: React.ReactNode | undefined) {
#renderContextWithProps(ContextProvider: ComponentType<WidgetsSettings | (WidgetsSettings & { children?: React.ReactNode })>, children: React.ReactNode | undefined) {
return <PropsProvider Component={ContextProvider} observable={this.#contextProps}>
{children}
</PropsProvider>;
}

#renderWidgets(contextProvider: ComponentType<AppSettings | (AppSettings & { children?: React.ReactNode })> | undefined) {
#renderWidgets(contextProvider: ComponentType<WidgetsSettings | (WidgetsSettings & { children?: React.ReactNode })> | undefined) {
const portals = activeWidgets.map(item =>
//this unique key is needed to avoid loosing the state of the component when some adjacent elements are removed.
<Fragment key={item.key}>
Expand All @@ -144,19 +156,19 @@ export class WidgetsManager<AppSettings = unknown> implements IWidgetsManager<Ap
initialized = false;
}

initialize(settings?: AppSettings) {
this.#contextProps.value = settings ?? {} as AppSettings;
initialize(settings?: WidgetsSettings) {
this.#contextProps.value = { ...this.#initialContextProps, ...(settings ?? {} as WidgetsSettings) };
initialized = true;
render();
}

update(settings: Partial<AppSettings>) {
update(settings: Partial<WidgetsSettings>) {
this.#contextProps.value = {
...this.#contextProps.value ?? {} as AppSettings,
...this.#contextProps.value ?? {} as WidgetsSettings,
...settings
};
}
get appSettings() {
get settings() {
return this.#contextProps.value;
}
}
2 changes: 1 addition & 1 deletion packages/r2wc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { type AttributeEventMap } from "./utils.ts";
export { WebComponentHTMLElement, WebComponentHTMLElementBase } from "./WebComponentHTMLElement.tsx";
export { WidgetsManager } from "./WidgetsManager.tsx";
export { WidgetsManager, type IWidgetsManager } from "./WidgetsManager.tsx";
Loading

0 comments on commit c0df5ec

Please sign in to comment.