Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/pluggableWidgets/pusher-web/.prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const base = require("@mendix/prettier-config-web-widgets");

module.exports = {
...base
};
11 changes: 11 additions & 0 deletions packages/pluggableWidgets/pusher-web/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

All notable changes to this widget will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Initial widget scaffolding
1 change: 1 addition & 0 deletions packages/pluggableWidgets/pusher-web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Pusher Widget
3 changes: 3 additions & 0 deletions packages/pluggableWidgets/pusher-web/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import config from "@mendix/eslint-config-web-widgets/widget-ts.mjs";

export default config;
63 changes: 63 additions & 0 deletions packages/pluggableWidgets/pusher-web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"name": "@mendix/pusher-web",
"widgetName": "Pusher",
"version": "2.0.0",
"description": "Pusher.com integration widget for real-time communication",
"copyright": "© Mendix Technology BV 2026. All rights reserved.",
"license": "Apache-2.0",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/mendix/web-widgets.git"
},
"config": {
"developmentPort": 3000,
"mendixHost": "http://localhost:8080"
},
"mxpackage": {
"name": "Pusher",
"type": "widget",
"mpkName": "com.mendix.widget.web.Pusher.mpk"
},
"packagePath": "com.mendix.widget.web",
"marketplace": {
"minimumMXVersion": "11.11.0",
"appName": "Pusher",
"reactReady": true
},
"testProject": {
"githubUrl": "https://github.com/mendix/testProjects",
"branchName": "pusher-web"
},
"scripts": {
"build": "pluggable-widgets-tools build:web",
"create-gh-release": "rui-create-gh-release",
"create-translation": "rui-create-translation",
"dev": "pluggable-widgets-tools start:web",
"e2e": "echo \"Skipping this e2e test\"",
"e2edev": "run-e2e dev --with-preps",
"format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .",
"lint": "eslint src/ package.json",
"release": "pluggable-widgets-tools release:web",
"start": "pluggable-widgets-tools start:server",
"test": "pluggable-widgets-tools test:unit:web",
"update-changelog": "rui-update-changelog-widget",
"verify": "rui-verify-package-format"
},
"dependencies": {
"classnames": "^2.5.1",
"pusher-js": "^8.5.0"
},
"devDependencies": {
"@mendix/automation-utils": "workspace:*",
"@mendix/eslint-config-web-widgets": "workspace:*",
"@mendix/pluggable-widgets-tools": "*",
"@mendix/prettier-config-web-widgets": "workspace:*",
"@mendix/run-e2e": "workspace:^*",
"@mendix/widget-plugin-component-kit": "workspace:*",
"@mendix/widget-plugin-hooks": "workspace:*",
"@mendix/widget-plugin-platform": "workspace:*",
"@mendix/widget-plugin-test-utils": "workspace:*",
"cross-env": "^7.0.3"
}
}
31 changes: 31 additions & 0 deletions packages/pluggableWidgets/pusher-web/src/Pusher.editorConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Properties } from "@mendix/pluggable-widgets-tools";
import {
container,
rowLayout,
structurePreviewPalette,
StructurePreviewProps,
text
} from "@mendix/widget-plugin-platform/preview/structure-preview-api";
import { PusherPreviewProps } from "../typings/PusherProps";

export function getProperties(_values: PusherPreviewProps, defaultProperties: Properties): Properties {
return defaultProperties;
}

export function getPreview(values: PusherPreviewProps, isDarkMode: boolean): StructurePreviewProps {
const palette = structurePreviewPalette[isDarkMode ? "dark" : "light"];

return rowLayout({ columnSize: "grow", borders: true, backgroundColor: palette.background.containerFill })(
container()(),
rowLayout({ grow: 2, padding: 8 })(text({ fontColor: palette.text.primary, grow: 10 })(getCaption(values))),
container()()
);
}

export function getCustomCaption(values: PusherPreviewProps): string {
return getCaption(values);
}

export function getCaption(values: PusherPreviewProps): string {
return `Pusher widget [${values.notifyActionName}]`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import classNames from "classnames";
import { ReactElement } from "react";
import { PusherPreviewProps } from "typings/PusherProps";
import { getCaption } from "./Pusher.editorConfig";
import "./ui/PusherPreview.css";

export function preview(props: PusherPreviewProps): ReactElement {
return <div className={classNames("widget-pusher-preview")}>{getCaption(props)}</div>;
}
48 changes: 48 additions & 0 deletions packages/pluggableWidgets/pusher-web/src/Pusher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import classnames from "classnames";
import { ReactElement, useCallback, useMemo } from "react";
import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action";
import { PusherContainerProps } from "../typings/PusherProps";
import { usePusherSubscribe } from "./hooks/usePusherSubscribe";
import "./ui/Pusher.scss";
import { getChannelName } from "./utils/getChannelName";

export default function Pusher(props: PusherContainerProps): ReactElement {
const { class: className, objectSource, notifyActionName, notifyEventAction } = props;

// Event callback - triggered when Pusher event is received
const handleEvent = useCallback(
(data: unknown) => {
console.debug("[Pusher] Event received:", data);

// Execute configured action
executeAction(notifyEventAction);
},
[notifyEventAction]
);

// Error callback
const handleError = useCallback((error: Error) => {
console.error("[Pusher] Subscription error:", error.message);
}, []);

// Build channel name based on the object
const channelName = getChannelName(objectSource as any); // TODO: fix typings when PWT updated.

// Setup stable subscription config
const subscription = useMemo(() => {
Comment thread
r0b1n marked this conversation as resolved.
if (!channelName) {
return undefined;
}

return {
channelName,
eventName: notifyActionName,
onEvent: handleEvent,
onError: handleError
};
}, [channelName, notifyActionName, handleEvent, handleError]);

usePusherSubscribe(subscription);

return <div className={classnames("widget-pusher", className)} />;
}
24 changes: 24 additions & 0 deletions packages/pluggableWidgets/pusher-web/src/Pusher.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8" ?>
<widget id="com.mendix.widget.web.pusher.Pusher" pluginWidget="true" offlineCapable="false" supportedPlatform="Web" xmlns="http://www.mendix.com/widget/1.0/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mendix.com/widget/1.0/ ../node_modules/mendix/custom_widget.xsd">
<name>Pusher</name>
<description>Listen to Notify server action and perform client side action</description>
<helpUrl>https://docs.mendix.com/appstore/widgets/pusher</helpUrl>
<properties>
<propertyGroup caption="General">
<property key="objectSource" type="datasource" isList="false">
<caption>Object to listen</caption>
<description />
</property>

<property key="notifyActionName" type="string" required="true" defaultValue="change">
<caption>Notify action name</caption>
<description>The name should match the with the 'Notify' parameter `ActionName`</description>
</property>

<property key="notifyEventAction" type="action" required="false">
<caption>Action</caption>
<description />
</property>
</propertyGroup>
</properties>
</widget>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
describe("Pusher", () => {
it("placeholder – tests to be implemented", () => {
// TODO: Add comprehensive unit tests for:
// - PusherListener class (connection, subscription, cleanup)
// - usePusherConfig hook (fetching config)
// - usePusherListener hook (React lifecycle integration)
// - Event handling and action execution
expect(true).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { createPusherListener } from "../utils/createPusherListener";
import { PusherListener, SubscriptionConfig } from "../utils/PusherListener";

/**
* Manages the full Pusher lifecycle: fetches config, creates the listener
* instance, and manages the channel subscription.
* Resubscribes automatically when the subscription config changes.
*/
export function usePusherSubscribe(subscription?: SubscriptionConfig): void {
const [listener, setListener] = useState<PusherListener | null>(null);

useEffect(() => {
const controller = new AbortController();
let instance: PusherListener | null = null;

createPusherListener(controller.signal).then(result => {
if (controller.signal.aborted) {
result?.destroy();
return;
}
instance = result;
setListener(result);
});

return () => {
controller.abort();
instance?.destroy();
setListener(null);
};
}, []);

useEffect(() => {
if (!listener) {
return;
}
if (!subscription) {
listener.unsubscribe();
return;
}

listener.subscribe(subscription);
return () => {
listener.unsubscribe();
};
}, [listener, subscription]);
}
11 changes: 11 additions & 0 deletions packages/pluggableWidgets/pusher-web/src/package.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<package xmlns="http://www.mendix.com/package/1.0/">
<clientModule name="PusherWidget" version="2.0.0" xmlns="http://www.mendix.com/clientModule/1.0/">
<widgetFiles>
<widgetFile path="Pusher.xml" />
</widgetFiles>
<files>
<file path="com/mendix/widget/web/pusher/" />
</files>
</clientModule>
</package>
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.widget-pusher-preview {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Pusher, { Channel } from "pusher-js";

export interface PusherConfig {
key: string;
cluster: string;
authEndpoint: string;
csrfToken: string;
}

export interface SubscriptionConfig {
channelName: string;
eventName: string;
onEvent: (data: unknown) => void;
onError?: (error: Error) => void;
}

export class PusherListener {
private pusher: Pusher;
private currentChannel: Channel | null = null;
private currentSubscription: SubscriptionConfig | null = null;

constructor(private config: PusherConfig) {
this.pusher = new Pusher(this.config.key, {
cluster: this.config.cluster,
authEndpoint: this.config.authEndpoint,
auth: {
headers: {
"X-Csrf-Token": this.config.csrfToken
}
}
});

// Setup connection event handlers
this.pusher.connection.bind("error", this.handleConnectionError);
this.pusher.connection.bind("state_change", this.handleStateChange);
}

/**
* Subscribe to channel for specific object and event
* Automatically unsubscribes from previous channel if different
*/
subscribe(config: SubscriptionConfig): void {
// If already subscribed to same channel and event, do nothing
if (config === this.currentSubscription) {
return;
}

// Unsubscribe from previous channel if exists
this.unsubscribe();

// Subscribe to new channel
this.currentSubscription = config;
this.currentChannel = this.pusher.subscribe(config.channelName);

// Bind event handler
this.currentChannel.bind(config.eventName, config.onEvent);

// Bind error handler
this.currentChannel.bind("pusher:subscription_error", (error: unknown) => {
console.error(error);
const errorMsg =
error === 515
? "Authentication failed. Please verify Pusher configuration constants."
: `Subscription error: ${String(error)}`;
config.onError?.(new Error(errorMsg));
});
}

/**
* Unsubscribe from current channel
*/
unsubscribe(): void {
if (this.currentChannel && this.currentSubscription) {
// Unbind all channel events
this.currentChannel.unbind();
this.pusher.unsubscribe(this.currentSubscription.channelName);
this.currentChannel = null;
this.currentSubscription = null;
}
}

/**
* Disconnect and cleanup
* Should be called on widget unmount
*/
destroy(): void {
this.unsubscribe();
this.pusher.connection.unbind();
this.pusher.disconnect();
}

private handleConnectionError = (error: unknown): void => {
console.error("[PusherListener] Connection error:", error);
};

private handleStateChange = (states: { previous: string; current: string }): void => {
console.debug(`[PusherListener] State changed: ${states.previous} → ${states.current}`);
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { fetchPusherConfig } from "./fetchPusherConfig";
import { PusherListener } from "./PusherListener";

/**
* Fetches Pusher configuration and creates a ready-to-use PusherListener.
* Returns null if the config fetch fails or the request is aborted.
*/
export async function createPusherListener(signal: AbortSignal): Promise<PusherListener | null> {
const config = await fetchPusherConfig(signal);
if (!config) {
return null;
}
return new PusherListener(config);
}
Loading
Loading