diff --git a/README.md b/README.md index 425fc35..fbf3e6b 100644 --- a/README.md +++ b/README.md @@ -98,14 +98,31 @@ See the [docs/](./docs/) folder for comprehensive API documentation: ### 🎯 Working Examples -See the [examples/](./examples/) folder for complete working examples: +#### Running Examples Locally + +```bash +# Install dependencies +pnpm install + +# Start the examples server +pnpm run dev:examples +``` + +This will open `http://localhost:3000` with an interactive gallery of all examples. + +#### Available Examples - **[Basic Usage](./examples/basic-usage/)** - Simple setup and device display - **[Device Selector](./examples/device-selector/)** - Interactive device selection with single/multi-select - **[Real-time Dashboard](./examples/real-time-dashboard/)** - Live data streaming and controls - **[Complete Widget](./examples/complete-widget/)** - Comprehensive example testing all features +- **[Event Versioning](./examples/event-versioning/)** - V1 and V2 event compatibility +- **[Widget Events - Basic](./examples/widget-events-basic/)** ⭐ NEW - Widget-specific events with `v2:widget::` pattern +- **[Widget Events - Multi](./examples/widget-events-multi/)** ⭐ NEW - Multiple widgets with isolated event namespaces - **[With HOCs](./examples/with-hocs/)** - Higher-Order Components usage +📖 **[Full Guide: How to Run Examples](./examples/RUNNING_EXAMPLES.md)** + ## Configurable Ready State You can control which events must occur before considering the system "ready": diff --git a/docs/EventVersioning.md b/docs/EventVersioning.md new file mode 100644 index 0000000..c21c341 --- /dev/null +++ b/docs/EventVersioning.md @@ -0,0 +1,154 @@ +# Event Versioning (V1 & V2) + +This library supports both V1 (legacy) and V2 event naming conventions for Ubidots dashboard communication. + +## Overview + +When V1 events are received or sent, the library **automatically emits the corresponding V2 events** as well. This ensures backward compatibility while supporting the new V2 event structure. + +## Event Mapping + +### Auth Events + +| V1 Event | V2 Event | Description | +| ------------------ | --------------- | ----------------------------- | +| `receivedToken` | `v2:auth:token` | Authentication token received | +| `receivedJWTToken` | `v2:auth:jwt` | JWT token received | + +### Dashboard Events + +| V1 Event | V2 Event | Description | +| ---------------------------- | --------------------------------- | ------------------------------------------------- | +| `selectedDevice` | `v2:dashboard:devices:selected` | Single device selected (converted to array in V2) | +| `selectedDevices` | `v2:dashboard:devices:selected` | Multiple devices selected | +| `selectedDashboardDateRange` | `v2:dashboard:settings:daterange` | Date range selected | +| `selectedDashboardObject` | `v2:dashboard:self` | Dashboard object received | +| `selectedFilters` | `v2:dashboard:settings:filters` | Filters selected | +| `isRealTimeActive` | `v2:dashboard:settings:rt` | Real-time status | + +### Outbound Events + +| V1 Event | V2 Event | Description | +| ----------------------------- | ---------------------------------- | -------------------- | +| `setDashboardDevice` | `v2:dashboard:devices:selected` | Set single device | +| `setDashboardMultipleDevices` | `v2:dashboard:devices:selected` | Set multiple devices | +| `setDashboardDateRange` | `v2:dashboard:settings:daterange` | Set date range | +| `setRealTime` | `v2:dashboard:settings:rt` | Set real-time mode | +| `refreshDashboard` | `v2:dashboard:settings:refreshed` | Refresh dashboard | +| `setFullScreen` | `v2:dashboard:settings:fullscreen` | Set fullscreen mode | +| `openDrawer` | `v2:dashboard:drawer:open` | Open drawer | + +## How It Works + +### Inbound Events (Receiving) + +When the library receives a V1 event, it: + +1. Processes the event normally (updates state, triggers callbacks) +2. **Automatically emits the corresponding V2 event** to the parent window + +Example: + +```typescript +// When 'receivedToken' arrives: +// 1. Updates state.token +// 2. Emits 'v2:auth:token' to parent window +``` + +The library can also receive V2 events directly and process them the same way. + +### Outbound Events (Sending) + +When you call an action (e.g., `setDashboardDevice`), the library: + +1. Sends the V1 event to the parent window +2. **Automatically sends the corresponding V2 event** as well + +Example: + +```typescript +const { setDashboardDevice } = useUbidotsActions(); + +// This sends BOTH: +// - 'setDashboardDevice' with deviceId +// - 'v2:dashboard:devices:selected' with [{ id: deviceId }] +setDashboardDevice('device-123'); +``` + +## Data Format Differences + +### Single Device vs Array + +V1 uses a single device or device ID string, while V2 always uses an array: + +```typescript +// V1 +setDashboardDevice('device-123'); +// Sends: { event: 'setDashboardDevice', payload: 'device-123' } + +// V2 (automatically sent) +// Sends: { event: 'v2:dashboard:devices:selected', payload: [{ id: 'device-123' }] } +``` + +## Usage + +No changes are required in your code! The library handles V2 events automatically: + +```tsx +import { UbidotsProvider, useUbidotsActions } from '@ubidots/react-html-canvas'; + +function MyWidget() { + const { setDashboardDevice } = useUbidotsActions(); + + // This automatically sends both V1 and V2 events + const handleClick = () => { + setDashboardDevice('device-123'); + }; + + return ; +} + +export default function App() { + return ( + + + + ); +} +``` + +## Ready Events + +You can use either V1 or V2 event names in the `readyEvents` prop: + +```tsx +// Using V1 events (legacy) + + + + +// Using V2 events + + + + +// Mixing both (not recommended, but supported) + + + +``` + +## Migration Guide + +If you're migrating from V1 to V2: + +1. **No immediate action required** - V1 events continue to work +2. **Gradual migration** - Update your event listeners to use V2 event names +3. **Update readyEvents** - Switch to V2 event names in your `UbidotsProvider` +4. **Test thoroughly** - Ensure both V1 and V2 events are being received correctly + +## Notes + +- Widget events (`v2:widget:*`) are **not** automatically emitted by this library +- The library maintains full backward compatibility with V1 events +- Both V1 and V2 events can be received and processed simultaneously diff --git a/examples/README.md b/examples/README.md index f11eea9..c938292 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,18 @@ Complete example demonstrating all available functionalities. Examples using Higher-Order Components to inject props. +### 6. **event-versioning** - Event Versioning + +Demonstrates V1 and V2 event compatibility and automatic event emission. + +### 7. **widget-events-basic** - Widget Events (Basic) + +Shows how to use widget-specific events with the `v2:widget::` pattern. Demonstrates event emission, monitoring, and lifecycle events. + +### 8. **widget-events-multi** - Widget Events (Multi-Widget) + +Advanced example showing multiple widgets on the same page with isolated event namespaces and inter-widget communication. + ## How to Use Examples Each example is an independent React component that you can copy and adapt to your project. diff --git a/examples/basic-usage/BasicUsage.jsx b/examples/basic-usage/BasicUsage.jsx index b4babe0..afdc677 100644 --- a/examples/basic-usage/BasicUsage.jsx +++ b/examples/basic-usage/BasicUsage.jsx @@ -5,13 +5,13 @@ import { useUbidotsSelectedDevice, useUbidotsActions, } from '@ubidots/react-html-canvas'; +import { EventEmitterPanel } from '../shared/EventEmitterPanel'; import './styles.css'; function DeviceInfo() { const ready = useUbidotsReady(); const device = useUbidotsSelectedDevice(); const { setDashboardDevice, refreshDashboard } = useUbidotsActions(); - if (!ready) { return (
@@ -76,6 +76,8 @@ export function BasicUsage() {
+ +
); diff --git a/examples/complete-widget/CompleteWidget.jsx b/examples/complete-widget/CompleteWidget.jsx index ccfb3de..5284584 100644 --- a/examples/complete-widget/CompleteWidget.jsx +++ b/examples/complete-widget/CompleteWidget.jsx @@ -14,6 +14,7 @@ import { useUbidotsActions, useUbidotsAPI, } from '@ubidots/react-html-canvas'; +import { EventEmitterPanel } from '../shared/EventEmitterPanel'; import './styles.css'; function DataDisplay() { @@ -369,6 +370,7 @@ export function CompleteWidget() { @ubidots/react-html-canvas

+ ); diff --git a/examples/dev/index.html b/examples/dev/index.html new file mode 100644 index 0000000..708d12c --- /dev/null +++ b/examples/dev/index.html @@ -0,0 +1,127 @@ + + + + + + Ubidots React HTML Canvas - Examples + + + +
+ + + diff --git a/examples/dev/main.jsx b/examples/dev/main.jsx new file mode 100644 index 0000000..fc220a6 --- /dev/null +++ b/examples/dev/main.jsx @@ -0,0 +1,194 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +// Import examples +import BasicUsage from '../basic-usage/BasicUsage.jsx'; +import DeviceSelector from '../device-selector/DeviceSelector.jsx'; +import RealTimeDashboard from '../real-time-dashboard/RealTimeDashboard.jsx'; +import CompleteWidget from '../complete-widget/CompleteWidget.jsx'; +import EventVersioningExample from '../event-versioning/EventVersioningExample.jsx'; +import WidgetEventsBasic from '../widget-events-basic/WidgetEventsBasic.jsx'; +import WidgetEventsMulti from '../widget-events-multi/WidgetEventsMulti.jsx'; +import WithHocsExample from '../with-hocs/WithHocsExample.jsx'; + +// Get example from URL query parameter +// eslint-disable-next-line no-undef +const urlParams = new URLSearchParams(window.location.search); +const exampleName = urlParams.get('example'); + +// Map example names to components +const examples = { + 'basic-usage': BasicUsage, + 'device-selector': DeviceSelector, + 'real-time-dashboard': RealTimeDashboard, + 'complete-widget': CompleteWidget, + 'event-versioning': EventVersioningExample, + 'widget-events-basic': WidgetEventsBasic, + 'widget-events-multi': WidgetEventsMulti, + 'with-hocs': WithHocsExample, +}; + +// Get the component to render +const ExampleComponent = examples[exampleName]; + +// Gallery component +function ExamplesGallery() { + return ( +
+
+
+

🚀 Ubidots React HTML Canvas

+

Interactive Examples & Demos

+
+ +
+ +

📱 Basic Usage

+

+ Simple setup showing core functionality with device display and + basic actions. +

+
+ Beginner + Provider + Hooks +
+
+ + +

🎯 Device Selector

+

+ Interactive device selection with single and multi-select + capabilities. +

+
+ Intermediate + Devices +
+
+ + +

⚡ Real-time Dashboard

+

Live data streaming and real-time controls demonstration.

+
+ Advanced + Real-time +
+
+ + +

🎨 Complete Widget

+

+ Comprehensive example testing all available features and + capabilities. +

+
+ Advanced + Complete +
+
+ + +

🔄 Event Versioning

+

V1 and V2 event compatibility with automatic event emission.

+
+ Intermediate + Events +
+
+ + +

🎯 Widget Events (Basic)

+

+ Widget-specific events with + v2:widget:<event>:<widgetId> pattern. +

+
+ NEW + Widget Events + V2 +
+
+ + +

🎭 Widget Events (Multi)

+

+ Multiple widgets with isolated namespaces and inter-widget + communication. +

+
+ NEW + Advanced + Multi-Widget +
+
+ + +

🔧 With HOCs

+

Higher-Order Components usage for prop injection.

+
+ Advanced + HOCs +
+
+
+ + +
+
+ ); +} + +// Render the example or show error +// eslint-disable-next-line no-undef +const root = ReactDOM.createRoot(document.getElementById('root')); + +if (ExampleComponent) { + root.render( + + + + ); +} else if (!exampleName) { + // Show the gallery + root.render( + + + + ); +} else { + root.render( +
+

❌ Example not found

+

The example does not exist.

+ + ← Back to examples + +
+ ); +} diff --git a/examples/device-selector/DeviceSelector.jsx b/examples/device-selector/DeviceSelector.jsx index 3be30e3..c040b29 100644 --- a/examples/device-selector/DeviceSelector.jsx +++ b/examples/device-selector/DeviceSelector.jsx @@ -6,6 +6,7 @@ import { useUbidotsSelectedDevices, useUbidotsActions, } from '@ubidots/react-html-canvas'; +import { EventEmitterPanel } from '../shared/EventEmitterPanel'; import './styles.css'; function DeviceCard({ device, isSelected, onSelect }) { @@ -233,6 +234,7 @@ export function DeviceSelectorExample() {

Interactive device selection with single and multi-select modes

+ ); diff --git a/examples/event-versioning/EventVersioningExample.jsx b/examples/event-versioning/EventVersioningExample.jsx new file mode 100644 index 0000000..5d0a7fc --- /dev/null +++ b/examples/event-versioning/EventVersioningExample.jsx @@ -0,0 +1,204 @@ +import React, { useEffect, useState } from 'react'; +import { + UbidotsProvider, + useUbidotsReady, + useUbidotsToken, + useUbidotsSelectedDevices, + useUbidotsActions, +} from '@ubidots/react-html-canvas'; +import { EventEmitterPanel } from '../shared/EventEmitterPanel'; + +/** + * Example demonstrating V1 and V2 event compatibility + * + * This example shows: + * 1. How V1 events automatically trigger V2 events + * 2. How to listen for both V1 and V2 events + * 3. How actions send both V1 and V2 events + */ + +function EventLogger() { + const [events, setEvents] = useState([]); + + useEffect(() => { + function handleMessage(ev) { + const { event, payload } = ev.data || {}; + if (event) { + const timestamp = new Date().toLocaleTimeString(); + setEvents(prev => + [ + ...prev, + { timestamp, event, payload: JSON.stringify(payload) }, + ].slice(-10) + ); // Keep last 10 events + } + } + + // eslint-disable-next-line no-undef + window.addEventListener('message', handleMessage); + // eslint-disable-next-line no-undef + return () => window.removeEventListener('message', handleMessage); + }, []); + + return ( +
+

Event Log (Last 10 events)

+ + + + + + + + + + {events.map((e, i) => ( + + + + + + ))} + +
TimeEventPayload
{e.timestamp}{e.event}{e.payload}
+
+ + V1 Events + {' '} + + V2 Events + +
+
+ ); +} + +function WidgetContent() { + const ready = useUbidotsReady(); + const token = useUbidotsToken(); + const devices = useUbidotsSelectedDevices(); + const { setDashboardDevice, setDashboardMultipleDevices, setRealTime } = + useUbidotsActions(); + + if (!ready) { + return
Waiting for ready state...
; + } + + return ( +
+

Event Versioning Demo

+ +
+

Current State

+

+ Token: {token ? '✓ Received' : '✗ Not received'} +

+

+ Selected Devices: {devices ? devices.length : 0} +

+
+ +
+

Actions (Send V1 + V2 Events)

+

+ Each button sends BOTH V1 and V2 events. Check the event log below. +

+ + + + + + +
+ + + +
+

How it works:

+
    +
  • + When you click a button, the library sends{' '} + both V1 and V2 events +
  • +
  • V1 events (orange) maintain backward compatibility
  • +
  • V2 events (blue) use the new naming convention
  • +
  • External listeners can subscribe to either version
  • +
+
+
+ ); +} + +export default function App() { + const handleReady = () => { + // eslint-disable-next-line no-console, no-undef + console.log('Widget ready!'); + }; + + return ( + + + + + ); +} diff --git a/examples/real-time-dashboard/RealTimeDashboard.jsx b/examples/real-time-dashboard/RealTimeDashboard.jsx index 7a19d2e..1da62fa 100644 --- a/examples/real-time-dashboard/RealTimeDashboard.jsx +++ b/examples/real-time-dashboard/RealTimeDashboard.jsx @@ -7,6 +7,7 @@ import { useUbidotsDashboardDateRange, useUbidotsActions, } from '@ubidots/react-html-canvas'; +import { EventEmitterPanel } from '../shared/EventEmitterPanel'; import './styles.css'; function RealTimeIndicator() { @@ -323,6 +324,7 @@ export function RealTimeDashboardExample() {
  • ✅ Automatic data updates when real-time is active
  • + ); diff --git a/examples/shared/EventEmitterPanel.css b/examples/shared/EventEmitterPanel.css new file mode 100644 index 0000000..95e9261 --- /dev/null +++ b/examples/shared/EventEmitterPanel.css @@ -0,0 +1,159 @@ +/* Event Emitter Panel - Floating */ +.event-panel { + position: fixed; + bottom: 20px; + right: 20px; + width: 400px; + max-height: 80vh; + background: white; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + z-index: 1000; + overflow: hidden; + transition: all 0.3s ease; +} + +.event-panel.closed { + width: 200px; + max-height: 60px; +} + +.event-panel-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 15px 20px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + user-select: none; +} + +.event-panel-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.toggle-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + width: 30px; + height: 30px; + border-radius: 50%; + cursor: pointer; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.toggle-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.event-panel-content { + max-height: calc(80vh - 60px); + overflow-y: auto; + padding: 15px; +} + +.last-emitted { + background: #e7f3ff; + padding: 8px 12px; + border-radius: 6px; + margin-bottom: 15px; + font-family: monospace; + font-size: 11px; + color: #0066cc; + border-left: 3px solid #0066cc; +} + +.event-categories { + display: flex; + flex-direction: column; + gap: 15px; +} + +.event-category { + border-bottom: 1px solid #e0e0e0; + padding-bottom: 15px; +} + +.event-category:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.event-category h4 { + margin: 0 0 10px 0; + font-size: 13px; + color: #555; + font-weight: 600; +} + +.event-buttons { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.event-btn { + padding: 6px 12px; + background: #f0f0f0; + border: 1px solid #ddd; + border-radius: 6px; + cursor: pointer; + font-size: 11px; + transition: all 0.2s; + color: #333; + font-weight: 500; +} + +.event-btn:hover { + background: #667eea; + color: white; + border-color: #667eea; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.event-btn:active { + transform: translateY(0); + box-shadow: 0 1px 4px rgba(102, 126, 234, 0.3); +} + +/* Scrollbar styling */ +.event-panel-content::-webkit-scrollbar { + width: 6px; +} + +.event-panel-content::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; +} + +.event-panel-content::-webkit-scrollbar-thumb { + background: #888; + border-radius: 10px; +} + +.event-panel-content::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Responsive */ +@media (max-width: 768px) { + .event-panel { + width: calc(100% - 40px); + right: 20px; + left: 20px; + } + + .event-panel.closed { + width: 200px; + left: auto; + } +} diff --git a/examples/shared/EventEmitterPanel.jsx b/examples/shared/EventEmitterPanel.jsx new file mode 100644 index 0000000..0325b70 --- /dev/null +++ b/examples/shared/EventEmitterPanel.jsx @@ -0,0 +1,272 @@ +import React, { useState } from 'react'; +import './EventEmitterPanel.css'; + +export function EventEmitterPanel() { + const [isOpen, setIsOpen] = useState(true); + const [lastEmitted, setLastEmitted] = useState(''); + + // Get widgetId from window if available, otherwise use a demo ID + const widgetId = + // eslint-disable-next-line no-undef + (typeof window !== 'undefined' && window.widgetId) || 'demo-widget-001'; + + const emitEvent = (eventName, payload) => { + const message = { event: eventName, payload }; + // eslint-disable-next-line no-undef + window.parent.postMessage(message, '*'); + setLastEmitted(`${eventName} - ${new Date().toLocaleTimeString()}`); + }; + + const events = [ + { + category: '⚡ System', + items: [ + { + name: 'Ready', + event: 'ready', + payload: { timestamp: Date.now() }, + }, + ], + }, + { + category: '🔐 Authentication', + items: [ + { + name: 'Token', + event: 'receivedToken', + payload: 'demo-token-12345', + }, + { + name: 'JWT Token', + event: 'receivedJWTToken', + payload: 'demo-jwt-token-67890', + }, + { + name: 'V2 Token', + event: 'v2:auth:token', + payload: 'demo-v2-token-abc', + }, + { + name: 'V2 JWT', + event: 'v2:auth:jwt', + payload: 'demo-v2-jwt-xyz', + }, + ], + }, + { + category: '📱 Device Selection', + items: [ + { + name: 'Single Device', + event: 'selectedDevice', + payload: 'device-001', + }, + { + name: 'Multiple Devices', + event: 'selectedDevices', + payload: [ + { id: 'device-001', name: 'Sensor 1', label: 'S1' }, + { id: 'device-002', name: 'Sensor 2', label: 'S2' }, + ], + }, + { + name: 'V2 Self Device', + event: 'v2:dashboard:devices:self', + payload: { id: 'device-self', name: 'Self Device' }, + }, + { + name: 'V2 Selected Devices', + event: 'v2:dashboard:devices:selected', + payload: [{ id: 'device-v2', name: 'V2 Device' }], + }, + ], + }, + { + category: '📅 Date & Time', + items: [ + { + name: 'Date Range', + event: 'selectedDashboardDateRange', + payload: { + startTime: Date.now() - 86400000, + endTime: Date.now(), + }, + }, + { + name: 'V2 Date Range', + event: 'v2:dashboard:settings:daterange', + payload: { + startTime: Date.now() - 3600000, + endTime: Date.now(), + }, + }, + { + name: 'Real-time ON', + event: 'isRealTimeActive', + payload: true, + }, + { + name: 'Real-time OFF', + event: 'isRealTimeActive', + payload: false, + }, + { + name: 'V2 Real-time', + event: 'v2:dashboard:settings:rt', + payload: true, + }, + ], + }, + { + category: '🎛️ Dashboard Objects', + items: [ + { + name: 'Dashboard Object', + event: 'selectedDashboardObject', + payload: { id: 'dash-001', name: 'Main Dashboard' }, + }, + { + name: 'Device Object', + event: 'selectedDeviceObject', + payload: { id: 'dev-obj-001', name: 'Device Config' }, + }, + { + name: 'V2 Dashboard Self', + event: 'v2:dashboard:self', + payload: { id: 'dash-v2', name: 'V2 Dashboard' }, + }, + ], + }, + { + category: '🔄 Dashboard Actions', + items: [ + { + name: 'Refresh Dashboard', + event: 'v2:dashboard:settings:refreshed', + payload: { timestamp: Date.now() }, + }, + ], + }, + { + category: '🎨 Filters', + items: [ + { + name: 'Selected Filters', + event: 'selectedFilters', + payload: [ + { key: 'status', value: 'active' }, + { key: 'type', value: 'sensor' }, + ], + }, + { + name: 'V2 Filters', + event: 'v2:dashboard:settings:filters', + payload: [{ filter: 'location', value: 'warehouse-1' }], + }, + ], + }, + { + category: '🔧 Widget Events', + items: [ + { + name: 'Widget Ready', + event: `v2:widget:ready:${widgetId}`, + payload: { + status: 'ok', + features: ['charts', 'realtime', 'export'], + timestamp: Date.now(), + }, + }, + { + name: 'Widget Loaded', + event: `v2:widget:loaded:${widgetId}`, + payload: { + timestamp: Date.now(), + version: '1.0.0', + }, + }, + { + name: 'Widget Error', + event: `v2:widget:error:${widgetId}`, + payload: { + code: 'DEMO_ERROR', + message: 'This is a simulated error', + timestamp: Date.now(), + }, + }, + { + name: 'Custom Action', + event: `v2:widget:customAction:${widgetId}`, + payload: { + action: 'export', + format: 'csv', + timestamp: Date.now(), + }, + }, + ], + }, + ]; + + const handleHeaderClick = () => setIsOpen(!isOpen); + const handleHeaderKeyDown = e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsOpen(!isOpen); + } + }; + + return ( +
    +
    +

    📡 Event Emitter

    + +
    + + {isOpen && ( +
    + {lastEmitted && ( +
    + Last: {lastEmitted} +
    + )} + +
    + {events.map((category, idx) => ( +
    +

    {category.category}

    +
    + {category.items.map((item, itemIdx) => ( + + ))} +
    +
    + ))} +
    +
    + )} +
    + ); +} diff --git a/examples/widget-events-basic/README.md b/examples/widget-events-basic/README.md new file mode 100644 index 0000000..e4d338f --- /dev/null +++ b/examples/widget-events-basic/README.md @@ -0,0 +1,137 @@ +# Widget Events - Basic Example + +This example demonstrates how to use **widget-specific V2 events** with the format `v2:widget::`. + +## Features + +- ✅ Emit widget lifecycle events (ready, loaded, error) +- 📡 Real-time event monitoring +- 🎯 Widget-specific event isolation +- 🔍 Event payload inspection + +## Event Format + +All widget events follow this pattern: + +``` +v2:widget:: +``` + +### Examples: + +- `v2:widget:ready:demo-widget-001` +- `v2:widget:loaded:demo-widget-001` +- `v2:widget:error:demo-widget-001` +- `v2:widget:settingsChanged:demo-widget-001` + +## Usage + +```jsx +import { UbidotsProvider, useWidgetEvents } from '@ubidots/react-html-canvas'; + +function MyWidget() { + const { emitWidgetEvent } = useWidgetEvents(); + + useEffect(() => { + // Emit widget ready event + emitWidgetEvent('ready', { status: 'ok' }); + }, []); + + return
    Widget Content
    ; +} + +export default function App() { + return ( + + + + ); +} +``` + +## Available Events + +### Standard Widget Events + +- **`ready`** - Widget is fully initialized and ready to use +- **`loaded`** - Widget has loaded its initial data +- **`error`** - Widget encountered an error +- **`settingsChanged`** - Widget settings were modified + +### Custom Events + +You can emit any custom event type: + +```jsx +emitWidgetEvent('customAction', { + action: 'export', + format: 'csv', +}); +// Emits: v2:widget:customAction:my-widget-123 +``` + +## Listening for Widget Events + +External applications can listen for widget events: + +```javascript +window.addEventListener('message', event => { + const { event: eventName, payload } = event.data; + + // Check if it's a widget event for a specific widget + if (eventName === 'v2:widget:ready:my-widget-123') { + console.log('Widget is ready!', payload); + } + + // Or check for any widget event + if (eventName.startsWith('v2:widget:')) { + const [, , eventType, widgetId] = eventName.split(':'); + console.log(`Widget ${widgetId} emitted ${eventType}`, payload); + } +}); +``` + +## Key Concepts + +### 1. Widget ID + +Each widget must have a unique ID passed to the `UbidotsProvider`: + +```jsx +{/* ... */} +``` + +### 2. Event Isolation + +Events are scoped to specific widgets using the widget ID. This allows: + +- Multiple widgets on the same page without event conflicts +- Targeted event handling for specific widgets +- Better debugging and monitoring + +### 3. Payload + +Events can include any JSON-serializable payload: + +```jsx +emitWidgetEvent('dataUpdated', { + recordCount: 150, + lastUpdate: new Date().toISOString(), + source: 'api', +}); +``` + +## Running the Example + +1. Import the component in your application +2. Ensure the widget is running inside a Ubidots dashboard or has proper postMessage setup +3. Open browser DevTools to see events in the console +4. Click buttons to emit different widget events +5. Watch the event monitor update in real-time + +## Notes + +- Widget events are **V2 only** (no V1 equivalent) +- Events are sent via `window.postMessage` to the parent window +- The widget ID must be unique across all widgets in the dashboard +- Event payloads should be kept reasonably small for performance diff --git a/examples/widget-events-basic/WidgetEventsBasic.jsx b/examples/widget-events-basic/WidgetEventsBasic.jsx new file mode 100644 index 0000000..ce2ad21 --- /dev/null +++ b/examples/widget-events-basic/WidgetEventsBasic.jsx @@ -0,0 +1,195 @@ +import React, { useEffect, useState } from 'react'; +import { + UbidotsProvider, + useUbidotsReady, + useWidgetEvents, +} from '@ubidots/react-html-canvas'; +import { EventEmitterPanel } from '../shared/EventEmitterPanel'; +import './styles.css'; + +/** + * Example demonstrating Widget-specific V2 events + * + * This example shows: + * 1. How to emit widget events with format: v2:widget:: + * 2. How to listen for widget-specific events + * 3. Widget lifecycle events (ready, loaded, error) + */ + +function EventMonitor({ widgetId }) { + const [receivedEvents, setReceivedEvents] = useState([]); + + useEffect(() => { + function handleMessage(ev) { + const { event, payload } = ev.data || {}; + + // Only log widget events for this specific widget + if ( + event && + event.startsWith(`v2:widget:`) && + event.endsWith(`:${widgetId}`) + ) { + const timestamp = new Date().toLocaleTimeString(); + const eventType = event.split(':')[2]; // Extract event type + + setReceivedEvents(prev => + [{ timestamp, event, eventType, payload }, ...prev].slice(0, 15) + ); // Keep last 15 events + } + } + + // eslint-disable-next-line no-undef + window.addEventListener('message', handleMessage); + // eslint-disable-next-line no-undef + return () => window.removeEventListener('message', handleMessage); + }, [widgetId]); + + return ( +
    +

    📡 Widget Event Monitor

    +

    + Listening for: v2:widget:*:{widgetId} +

    + +
    + {receivedEvents.length === 0 ? ( +
    No events received yet
    + ) : ( + receivedEvents.map((e, i) => ( +
    + {e.timestamp} + + {e.eventType} + + {JSON.stringify(e.payload)} +
    + )) + )} +
    +
    + ); +} + +function WidgetContent({ widgetId }) { + const ready = useUbidotsReady(); + const { emitWidgetEvent } = useWidgetEvents(); + const [status, setStatus] = useState('initializing'); + + useEffect(() => { + if (ready) { + // Emit 'loaded' event when widget is ready + emitWidgetEvent('loaded', { + timestamp: Date.now(), + version: '1.0.0', + }); + setStatus('ready'); + } + }, [ready, emitWidgetEvent]); + + const handleReady = () => { + emitWidgetEvent('ready', { + status: 'ok', + features: ['charts', 'realtime', 'export'], + }); + setStatus('active'); + }; + + const handleError = () => { + emitWidgetEvent('error', { + code: 'DEMO_ERROR', + message: 'This is a simulated error for demonstration', + }); + setStatus('error'); + }; + + const handleSettingsChange = () => { + emitWidgetEvent('settingsChanged', { + theme: 'dark', + refreshInterval: 5000, + showLegend: true, + }); + }; + + const handleCustomEvent = () => { + emitWidgetEvent('customAction', { + action: 'export', + format: 'csv', + timestamp: Date.now(), + }); + }; + + if (!ready) { + return ( +
    +

    🔄 Initializing widget...

    +
    + ); + } + + return ( +
    +
    +

    + Widget Status: {status} +

    +

    + Widget ID: {widgetId} +

    +
    + +
    +

    🎯 Emit Widget Events

    +

    + Click buttons to emit events with format:{' '} + v2:widget:<event>:{widgetId} +

    + +
    + + + + + + + +
    +
    +
    + ); +} + +export function WidgetEventsBasic() { + const widgetId = 'widget-demo-001'; + + return ( + +
    +
    +

    🎨 Widget Events - Basic Example

    +

    Demonstrating V2 widget-specific events

    +
    + +
    +
    + +
    + +
    + +
    +
    + +
    +
    + ); +} + +export default WidgetEventsBasic; diff --git a/examples/widget-events-basic/styles.css b/examples/widget-events-basic/styles.css new file mode 100644 index 0000000..c93b829 --- /dev/null +++ b/examples/widget-events-basic/styles.css @@ -0,0 +1,254 @@ +.container { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, + Cantarell, sans-serif; + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.header { + text-align: center; + margin-bottom: 30px; + padding: 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 10px; +} + +.header h1 { + margin: 0 0 10px 0; + font-size: 2em; +} + +.header p { + margin: 0; + opacity: 0.9; +} + +.main-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-top: 20px; +} + +@media (max-width: 768px) { + .main-layout { + grid-template-columns: 1fr; + } +} + +/* Widget Panel */ +.widget-panel { + background: white; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.loading { + text-align: center; + padding: 40px; + color: #666; +} + +.status-bar { + background: #f5f5f5; + padding: 15px; + border-radius: 6px; + margin-bottom: 20px; +} + +.status-bar h3 { + margin: 0 0 10px 0; + font-size: 1.2em; +} + +.status-initializing { + color: #ff9800; +} +.status-ready { + color: #2196f3; +} +.status-active { + color: #4caf50; +} +.status-error { + color: #f44336; +} + +.widget-id { + margin: 5px 0 0 0; + font-size: 0.9em; + color: #666; +} + +.widget-id code { + background: #e0e0e0; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', monospace; +} + +.actions-section { + margin-top: 20px; +} + +.actions-section h3 { + margin: 0 0 10px 0; +} + +.actions-info { + font-size: 0.9em; + color: #666; + margin-bottom: 15px; +} + +.actions-info code { + background: #fff3cd; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', monospace; + color: #856404; +} + +.button-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.btn { + padding: 12px 20px; + border: none; + border-radius: 6px; + font-size: 1em; + cursor: pointer; + transition: all 0.2s; + font-weight: 500; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.btn-success { + background: #4caf50; + color: white; +} + +.btn-danger { + background: #f44336; + color: white; +} + +.btn-primary { + background: #2196f3; + color: white; +} + +.btn-info { + background: #00bcd4; + color: white; +} + +/* Event Monitor */ +.monitor-panel { + background: white; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.event-monitor h3 { + margin: 0 0 10px 0; +} + +.monitor-info { + font-size: 0.9em; + color: #666; + margin-bottom: 15px; +} + +.monitor-info code { + background: #e3f2fd; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', monospace; + color: #1976d2; +} + +.event-list { + max-height: 400px; + overflow-y: auto; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 10px; +} + +.no-events { + text-align: center; + padding: 20px; + color: #999; + font-style: italic; +} + +.event-item { + display: grid; + grid-template-columns: 80px 120px 1fr; + gap: 10px; + padding: 8px; + margin-bottom: 8px; + background: #f9f9f9; + border-radius: 4px; + font-size: 0.85em; + align-items: center; +} + +.event-time { + color: #666; + font-family: 'Courier New', monospace; +} + +.event-type { + padding: 4px 8px; + border-radius: 4px; + font-weight: 600; + text-align: center; + font-size: 0.9em; +} + +.event-type-ready { + background: #c8e6c9; + color: #2e7d32; +} + +.event-type-loaded { + background: #bbdefb; + color: #1565c0; +} + +.event-type-error { + background: #ffcdd2; + color: #c62828; +} + +.event-type-settingsChanged { + background: #fff9c4; + color: #f57f17; +} + +.event-type-customAction { + background: #e1bee7; + color: #6a1b9a; +} + +.event-payload { + font-family: 'Courier New', monospace; + color: #333; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/examples/widget-events-multi/README.md b/examples/widget-events-multi/README.md new file mode 100644 index 0000000..1bcce35 --- /dev/null +++ b/examples/widget-events-multi/README.md @@ -0,0 +1,199 @@ +# Widget Events - Multi Widget Example + +This example demonstrates multiple widgets on the same page, each with isolated event namespaces using the `v2:widget::` pattern. + +## Features + +- 🎭 Multiple independent widgets on the same page +- 🔒 Event isolation per widget using unique IDs +- 📡 Inter-widget communication via broadcast events +- 🌐 Global event monitoring across all widgets +- 🎯 Selective event listening (widgets ignore their own events) + +## Event Isolation + +Each widget has its own event namespace: + +``` +Widget Alpha: v2:widget::widget-alpha +Widget Beta: v2:widget::widget-beta +Widget Gamma: v2:widget::widget-gamma +``` + +This prevents event conflicts and allows widgets to: + +- Emit events without affecting other widgets +- Listen for events from specific widgets +- Broadcast messages to all other widgets + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ Global Event Monitor │ +│ (Listens to all v2:widget:* events) │ +└─────────────────────────────────────────┘ + ▲ + │ + ┌───────────┼───────────┐ + │ │ │ + ┌────▼────┐ ┌───▼─────┐ ┌──▼──────┐ + │ Widget │ │ Widget │ │ Widget │ + │ Alpha │ │ Beta │ │ Gamma │ + └─────────┘ └─────────┘ └─────────┘ +``` + +## Usage + +### Basic Setup + +```jsx +import { UbidotsProvider, useWidgetEvents } from '@ubidots/react-html-canvas'; + +function Widget({ widgetId }) { + const { emitWidgetEvent } = useWidgetEvents(); + + const sendMessage = () => { + emitWidgetEvent('message', { + text: `Hello from ${widgetId}`, + }); + }; + + return ; +} + +// Each widget needs its own Provider with unique widgetId +function App() { + return ( + <> + + + + + + + + + ); +} +``` + +### Inter-Widget Communication + +Widgets can listen for events from other widgets: + +```jsx +function Widget({ widgetId }) { + const [messages, setMessages] = useState([]); + + useEffect(() => { + function handleMessage(ev) { + const { event, payload } = ev.data || {}; + + // Listen for 'message' events from OTHER widgets + if ( + event && + event.startsWith('v2:widget:message:') && + !event.endsWith(`:${widgetId}`) + ) { + const senderWidgetId = event.split(':')[3]; + setMessages(prev => [ + ...prev, + { + from: senderWidgetId, + text: payload.text, + }, + ]); + } + } + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [widgetId]); + + return ( +
    + {messages.map((msg, i) => ( +
    + From {msg.from}: {msg.text} +
    + ))} +
    + ); +} +``` + +## Event Flow Example + +When Widget Alpha sends a message: + +``` +1. Widget Alpha calls: emitWidgetEvent('message', { text: 'Hello' }) +2. Event emitted: v2:widget:message:widget-alpha +3. Global Monitor receives and displays the event +4. Widget Beta receives the event (different widgetId) +5. Widget Gamma receives the event (different widgetId) +6. Widget Alpha ignores it (same widgetId) +``` + +## Key Concepts + +### 1. Unique Widget IDs + +Each widget MUST have a unique `widgetId`: + +```jsx + + + +``` + +### 2. Event Filtering + +Widgets can filter events by: + +- **Event type**: `v2:widget:message:*` +- **Widget ID**: `v2:widget:*:widget-alpha` +- **Both**: `v2:widget:message:widget-alpha` + +### 3. Broadcast Pattern + +To broadcast to all widgets: + +```jsx +// Sender +emitWidgetEvent('broadcast', { data: 'for everyone' }); + +// Receivers (all other widgets) +if ( + event.startsWith('v2:widget:broadcast:') && + !event.endsWith(`:${myWidgetId}`) +) { + // Handle broadcast +} +``` + +## Use Cases + +- **Dashboard with multiple charts**: Each chart is a widget that can update others +- **Collaborative tools**: Widgets share state changes +- **Event logging**: Central monitor tracks all widget activities +- **Widget orchestration**: One widget controls others + +## Running the Example + +```bash +pnpm install +pnpm dev +``` + +Then interact with the widgets to see: + +- Events appearing in the global monitor +- Messages being received by other widgets +- Event isolation in action + +## See Also + +- [Widget Events - Basic Example](../widget-events-basic/) - Single widget events +- [Event Versioning Example](../event-versioning/) - V1/V2 compatibility diff --git a/examples/widget-events-multi/WidgetEventsMulti.jsx b/examples/widget-events-multi/WidgetEventsMulti.jsx new file mode 100644 index 0000000..6ec21cf --- /dev/null +++ b/examples/widget-events-multi/WidgetEventsMulti.jsx @@ -0,0 +1,223 @@ +import React, { useEffect, useState } from 'react'; +import { + UbidotsProvider, + useUbidotsReady, + useWidgetEvents, +} from '@ubidots/react-html-canvas'; +import { EventEmitterPanel } from '../shared/EventEmitterPanel'; +import './styles.css'; + +/** + * Example demonstrating multiple widgets with isolated events + * + * This example shows: + * 1. Multiple widgets on the same page + * 2. Each widget has its own event namespace + * 3. Widgets can communicate with each other via events + * 4. Event isolation prevents cross-widget interference + */ + +function GlobalEventMonitor() { + const [allEvents, setAllEvents] = useState([]); + + useEffect(() => { + function handleMessage(ev) { + const { event, payload } = ev.data || {}; + + // Log all widget events + if (event && event.startsWith('v2:widget:')) { + const timestamp = new Date().toLocaleTimeString(); + const parts = event.split(':'); + const eventType = parts[2]; + const widgetId = parts[3]; + + setAllEvents(prev => + [{ timestamp, event, eventType, widgetId, payload }, ...prev].slice( + 0, + 20 + ) + ); // Keep last 20 events + } + } + + // eslint-disable-next-line no-undef + window.addEventListener('message', handleMessage); + // eslint-disable-next-line no-undef + return () => window.removeEventListener('message', handleMessage); + }, []); + + return ( +
    +

    🌐 Global Event Monitor

    +

    All widget events across the page

    + +
    + {allEvents.length === 0 ? ( +
    + No events yet. Interact with widgets below. +
    + ) : ( + allEvents.map((e, i) => ( +
    + {e.timestamp} + + {e.widgetId} + + {e.eventType} + {JSON.stringify(e.payload)} +
    + )) + )} +
    +
    + ); +} + +function Widget({ widgetId, color }) { + const ready = useUbidotsReady(); + const { emitWidgetEvent } = useWidgetEvents(); + const [messageCount, setMessageCount] = useState(0); + const [receivedMessages, setReceivedMessages] = useState([]); + + useEffect(() => { + if (ready) { + emitWidgetEvent('ready', { color, timestamp: Date.now() }); + } + }, [ready, emitWidgetEvent, color]); + + // Listen for messages from OTHER widgets + useEffect(() => { + function handleMessage(ev) { + const { event, payload } = ev.data || {}; + + // Only listen to 'message' events from OTHER widgets + if ( + event && + event.startsWith('v2:widget:message:') && + !event.endsWith(`:${widgetId}`) + ) { + const senderWidgetId = event.split(':')[3]; + setReceivedMessages(prev => + [ + { + from: senderWidgetId, + text: payload.text, + time: new Date().toLocaleTimeString(), + }, + ...prev, + ].slice(0, 5) + ); + } + } + + // eslint-disable-next-line no-undef + window.addEventListener('message', handleMessage); + // eslint-disable-next-line no-undef + return () => window.removeEventListener('message', handleMessage); + }, [widgetId]); + + const sendMessage = () => { + const count = messageCount + 1; + setMessageCount(count); + + emitWidgetEvent('message', { + text: `Hello from ${widgetId}! (Message #${count})`, + timestamp: Date.now(), + }); + }; + + const sendAction = () => { + emitWidgetEvent('action', { + type: 'buttonClick', + widgetId, + timestamp: Date.now(), + }); + }; + + if (!ready) { + return
    Loading...
    ; + } + + return ( +
    +
    +

    {widgetId}

    + ● Ready +
    + +
    +
    + + +
    + +
    + Messages sent: {messageCount} +
    + + {receivedMessages.length > 0 && ( +
    +

    📨 Received Messages

    + {receivedMessages.map((msg, i) => ( +
    + {msg.time} + from {msg.from}: + {msg.text} +
    + ))} +
    + )} +
    +
    + ); +} + +function WidgetWrapper({ widgetId, color }) { + return ( + + + + ); +} + +export function WidgetEventsMulti() { + return ( +
    +
    +

    🎭 Multi-Widget Events Example

    +

    Multiple widgets with isolated event namespaces

    +
    + + + +
    + + + +
    + +
    +

    💡 How it works

    +
      +
    • + Each widget has its own widgetId +
    • +
    • + Events are namespaced:{' '} + v2:widget:<event>:<widgetId> +
    • +
    • Widgets can broadcast messages to all other widgets
    • +
    • The global monitor shows all events from all widgets
    • +
    • Each widget only receives messages from OTHER widgets
    • +
    +
    + +
    + ); +} + +export default WidgetEventsMulti; diff --git a/examples/widget-events-multi/styles.css b/examples/widget-events-multi/styles.css new file mode 100644 index 0000000..2dcf66c --- /dev/null +++ b/examples/widget-events-multi/styles.css @@ -0,0 +1,277 @@ +.app-container { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, + Cantarell, sans-serif; + max-width: 1400px; + margin: 0 auto; + padding: 20px; + background: #f5f5f5; +} + +.app-header { + text-align: center; + padding: 30px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 12px; + margin-bottom: 30px; +} + +.app-header h1 { + margin: 0 0 10px 0; + font-size: 2.5em; +} + +.app-header p { + margin: 0; + opacity: 0.9; + font-size: 1.1em; +} + +/* Global Monitor */ +.global-monitor { + background: white; + border-radius: 10px; + padding: 25px; + margin-bottom: 30px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.global-monitor h2 { + margin: 0 0 5px 0; + color: #333; +} + +.monitor-subtitle { + margin: 0 0 20px 0; + color: #666; + font-size: 0.9em; +} + +.event-stream { + max-height: 300px; + overflow-y: auto; + background: #f8f9fa; + border-radius: 6px; + padding: 10px; +} + +.no-events { + text-align: center; + padding: 40px; + color: #999; + font-style: italic; +} + +.event-row { + display: grid; + grid-template-columns: 80px 120px 120px 1fr; + gap: 10px; + padding: 10px; + margin-bottom: 5px; + background: white; + border-radius: 4px; + font-size: 0.85em; + align-items: center; +} + +.event-time { + color: #666; + font-family: 'Courier New', monospace; +} + +.widget-badge { + padding: 4px 8px; + border-radius: 4px; + font-weight: 600; + font-size: 0.85em; + text-align: center; + color: white; +} + +.widget-widget-alpha { + background: #4caf50; +} +.widget-widget-beta { + background: #2196f3; +} +.widget-widget-gamma { + background: #ff9800; +} + +.event-name { + font-family: 'Courier New', monospace; + color: #495057; + font-weight: 500; +} + +.event-data { + font-family: 'Courier New', monospace; + color: #6c757d; + font-size: 0.9em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Widgets Grid */ +.widgets-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + margin-bottom: 30px; +} + +/* Widget Card */ +.widget-card { + background: white; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border: 3px solid; + transition: transform 0.2s; +} + +.widget-card:hover { + transform: translateY(-4px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); +} + +.widget-header { + padding: 15px 20px; + color: white; + display: flex; + justify-content: space-between; + align-items: center; +} + +.widget-header h3 { + margin: 0; + font-size: 1.2em; +} + +.widget-status { + font-size: 0.9em; + opacity: 0.9; +} + +.widget-body { + padding: 20px; +} + +.widget-loading { + padding: 40px; + text-align: center; + color: #999; +} + +.widget-actions { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.widget-actions button { + flex: 1; + padding: 10px 15px; + border: none; + border-radius: 6px; + font-size: 0.9em; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn-send { + background: #28a745; + color: white; +} + +.btn-send:hover { + background: #218838; + transform: scale(1.05); +} + +.btn-action { + background: #6c757d; + color: white; +} + +.btn-action:hover { + background: #5a6268; + transform: scale(1.05); +} + +.message-counter { + padding: 10px; + background: #f8f9fa; + border-radius: 4px; + text-align: center; + margin-bottom: 15px; + color: #495057; +} + +.received-messages { + border-top: 2px solid #e9ecef; + padding-top: 15px; +} + +.received-messages h4 { + margin: 0 0 10px 0; + font-size: 1em; + color: #495057; +} + +.message-item { + padding: 8px; + background: #e7f3ff; + border-left: 3px solid #2196f3; + border-radius: 4px; + margin-bottom: 8px; + font-size: 0.85em; +} + +.message-time { + color: #666; + font-family: 'Courier New', monospace; + margin-right: 8px; +} + +.message-from { + color: #2196f3; + font-weight: 600; + margin-right: 8px; +} + +.message-text { + color: #333; +} + +/* Info Panel */ +.info-panel { + background: white; + border-radius: 10px; + padding: 25px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.info-panel h3 { + margin: 0 0 15px 0; + color: #333; +} + +.info-panel ul { + margin: 0; + padding-left: 20px; + color: #495057; + line-height: 1.8; +} + +.info-panel code { + background: #f8f9fa; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', monospace; + color: #e83e8c; + font-size: 0.9em; +} diff --git a/examples/with-hocs/WithHocsExample.jsx b/examples/with-hocs/WithHocsExample.jsx index c2ef7f0..dc4ea28 100644 --- a/examples/with-hocs/WithHocsExample.jsx +++ b/examples/with-hocs/WithHocsExample.jsx @@ -5,6 +5,7 @@ import { withUbidotsActions, compose, } from '@ubidots/react-html-canvas'; +import { EventEmitterPanel } from '../shared/EventEmitterPanel'; import './styles.css'; function DeviceDisplay({ selectedDevice, title = 'Device Information' }) { @@ -286,6 +287,7 @@ export function WithHocsExample() { + ); diff --git a/package.json b/package.json index a23a6cc..d8a8934 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "build": "vite build", "build:watch": "vite build --watch", "dev": "vite", + "dev:examples": "vite --config vite.examples.config.ts", "serve": "python3 -m http.server 8080 --directory dist", "serve:tunnel": "pnpm run serve & cloudflared tunnel --url http://localhost:8080", "dev:cdn": "concurrently \"pnpm run build:watch\" \"pnpm run serve\"", diff --git a/src/context/UbidotsReducer.ts b/src/context/UbidotsReducer.ts index e16e8e4..5dff7f4 100644 --- a/src/context/UbidotsReducer.ts +++ b/src/context/UbidotsReducer.ts @@ -23,6 +23,7 @@ export const initialState: UbidotsState = { selectedFilters: null, realTime: null, widget: null, + widgetId: null, }; /** @@ -80,6 +81,10 @@ const reducerHandlers: Record< ...state, widget: action.payload as WidgetInfo | null, }), + [ACTION_TYPES.SET_WIDGET_ID]: (state, action) => ({ + ...state, + widgetId: action.payload as string | null, + }), }; export function ubidotsReducer( @@ -87,5 +92,6 @@ export function ubidotsReducer( action: UbidotsAction ): UbidotsState { const handler = reducerHandlers[action.type]; - return handler ? handler(state, action) : state; + const r = handler ? handler(state, action) : state; + return r; } diff --git a/src/context/__tests__/UbidotsReducer.test.ts b/src/context/__tests__/UbidotsReducer.test.ts index d0bef26..444e7fa 100644 --- a/src/context/__tests__/UbidotsReducer.test.ts +++ b/src/context/__tests__/UbidotsReducer.test.ts @@ -42,4 +42,30 @@ describe('ubidotsReducer', () => { const s2 = ubidotsReducer(s1, { type: 'SET_READY', payload: true }); expect(s2.ready).toBe(true); }); + + it('should handle SET_WIDGET_ID action', () => { + const s1 = ubidotsReducer(initialState, { + type: 'SET_WIDGET_ID', + payload: 'widget-123', + }); + expect(s1.widgetId).toBe('widget-123'); + }); + + it('should handle SET_WIDGET_ID with null payload', () => { + const stateWithWidgetId = ubidotsReducer(initialState, { + type: 'SET_WIDGET_ID', + payload: 'widget-abc', + }); + expect(stateWithWidgetId.widgetId).toBe('widget-abc'); + + const s2 = ubidotsReducer(stateWithWidgetId, { + type: 'SET_WIDGET_ID', + payload: null, + }); + expect(s2.widgetId).toBeNull(); + }); + + it('should have widgetId as null in initial state', () => { + expect(initialState.widgetId).toBeNull(); + }); }); diff --git a/src/context/__tests__/actions.test.ts b/src/context/__tests__/actions.test.ts new file mode 100644 index 0000000..b763d85 --- /dev/null +++ b/src/context/__tests__/actions.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createActions } from '../actions'; +import { OUTBOUND_EVENTS, OUTBOUND_EVENTS_V2 } from '../constants'; + +describe('actions', () => { + let postMessageSpy: ReturnType; + + beforeEach(() => { + postMessageSpy = vi.spyOn(window.parent, 'postMessage'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + postMessageSpy.mockRestore(); + vi.restoreAllMocks(); + // Clean up widgetId from window + delete (window as unknown as Record).widgetId; + }); + + describe('setDashboardDevice', () => { + it('should emit both V1 and V2 events', () => { + const actions = createActions(null, 'token'); + + actions.setDashboardDevice('device-123'); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.SET_DASHBOARD_DEVICE, payload: 'device-123' }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { + event: OUTBOUND_EVENTS_V2.SET_DASHBOARD_DEVICE, + payload: [{ id: 'device-123' }], + }, + '*' + ); + }); + + it('should convert single device ID to array format for V2', () => { + const actions = createActions(null, 'token'); + + actions.setDashboardDevice('my-device'); + + const v2Call = postMessageSpy.mock.calls.find( + call => + (call[0] as { event: string }).event === + OUTBOUND_EVENTS_V2.SET_DASHBOARD_DEVICE + ); + expect((v2Call![0] as { payload: unknown }).payload).toEqual([ + { id: 'my-device' }, + ]); + }); + }); + + describe('setDashboardMultipleDevices', () => { + it('should emit both V1 and V2 events', () => { + const actions = createActions(null, 'token'); + + actions.setDashboardMultipleDevices(['dev1', 'dev2', 'dev3']); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + event: OUTBOUND_EVENTS.SET_DASHBOARD_MULTIPLE_DEVICES, + payload: ['dev1', 'dev2', 'dev3'], + }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { + event: OUTBOUND_EVENTS_V2.SET_DASHBOARD_MULTIPLE_DEVICES, + payload: [{ id: 'dev1' }, { id: 'dev2' }, { id: 'dev3' }], + }, + '*' + ); + }); + + it('should convert string array to Device array for V2', () => { + const actions = createActions(null, 'token'); + + actions.setDashboardMultipleDevices(['a', 'b']); + + const v2Call = postMessageSpy.mock.calls.find( + call => + (call[0] as { event: string }).event === + OUTBOUND_EVENTS_V2.SET_DASHBOARD_MULTIPLE_DEVICES + ); + expect((v2Call![0] as { payload: unknown }).payload).toEqual([ + { id: 'a' }, + { id: 'b' }, + ]); + }); + }); + + describe('setDashboardDateRange', () => { + it('should emit both V1 and V2 events for valid date range', () => { + const actions = createActions(null, 'token'); + const dateRange = { startTime: 1000, endTime: 2000 }; + + actions.setDashboardDateRange(dateRange); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.SET_DASHBOARD_DATE_RANGE, payload: dateRange }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { + event: OUTBOUND_EVENTS_V2.SET_DASHBOARD_DATE_RANGE, + payload: dateRange, + }, + '*' + ); + }); + + it('should NOT emit events for invalid date range (startTime >= endTime)', () => { + const actions = createActions(null, 'token'); + const invalidRange = { startTime: 2000, endTime: 1000 }; + + actions.setDashboardDateRange(invalidRange); + + expect(postMessageSpy).not.toHaveBeenCalled(); + }); + + it('should NOT emit events for null date range', () => { + const actions = createActions(null, 'token'); + + actions.setDashboardDateRange( + null as unknown as { startTime: number; endTime: number } + ); + + expect(postMessageSpy).not.toHaveBeenCalled(); + }); + }); + + describe('setDashboardLayer', () => { + it('should only emit V1 event (no V2 equivalent)', () => { + const actions = createActions(null, 'token'); + + actions.setDashboardLayer('layer-1'); + + expect(postMessageSpy).toHaveBeenCalledTimes(1); + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.SET_DASHBOARD_LAYER, payload: 'layer-1' }, + '*' + ); + }); + }); + + describe('setRealTime', () => { + it('should emit both V1 and V2 events with true', () => { + const actions = createActions(null, 'token'); + + actions.setRealTime(true); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.SET_REAL_TIME, payload: true }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS_V2.SET_REAL_TIME, payload: true }, + '*' + ); + }); + + it('should emit both V1 and V2 events with false', () => { + const actions = createActions(null, 'token'); + + actions.setRealTime(false); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.SET_REAL_TIME, payload: false }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS_V2.SET_REAL_TIME, payload: false }, + '*' + ); + }); + }); + + describe('refreshDashboard', () => { + it('should emit both V1 and V2 events without payload', () => { + const actions = createActions(null, 'token'); + + actions.refreshDashboard(); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.REFRESH_DASHBOARD, payload: undefined }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS_V2.REFRESH_DASHBOARD, payload: undefined }, + '*' + ); + }); + }); + + describe('openDrawer', () => { + it('should emit both V1 and V2 events with drawer info', () => { + const actions = createActions(null, 'token'); + const drawerOpts = { url: 'https://example.com', width: 400 }; + + actions.openDrawer(drawerOpts); + + // When widgetId is not set on window, it will be undefined + const expectedPayload = { + drawerInfo: drawerOpts, + id: undefined, + }; + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.OPEN_DRAWER, payload: expectedPayload }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS_V2.OPEN_DRAWER, payload: expectedPayload }, + '*' + ); + }); + + it('should use widgetId from window if available', () => { + (window as unknown as Record).widgetId = + 'custom-widget-id'; + const actions = createActions(null, 'token'); + const drawerOpts = { url: 'https://example.com', width: 300 }; + + actions.openDrawer(drawerOpts); + + const expectedPayload = { + drawerInfo: drawerOpts, + id: 'custom-widget-id', + }; + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.OPEN_DRAWER, payload: expectedPayload }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS_V2.OPEN_DRAWER, payload: expectedPayload }, + '*' + ); + }); + }); + + describe('setFullScreen', () => { + it('should emit both V1 and V2 events with toggle', () => { + const actions = createActions(null, 'token'); + + actions.setFullScreen('toggle'); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.SET_FULL_SCREEN, payload: 'toggle' }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS_V2.SET_FULL_SCREEN, payload: 'toggle' }, + '*' + ); + }); + + it('should emit both V1 and V2 events with enable', () => { + const actions = createActions(null, 'token'); + + actions.setFullScreen('enable'); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.SET_FULL_SCREEN, payload: 'enable' }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS_V2.SET_FULL_SCREEN, payload: 'enable' }, + '*' + ); + }); + + it('should emit both V1 and V2 events with disable', () => { + const actions = createActions(null, 'token'); + + actions.setFullScreen('disable'); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS.SET_FULL_SCREEN, payload: 'disable' }, + '*' + ); + expect(postMessageSpy).toHaveBeenCalledWith( + { event: OUTBOUND_EVENTS_V2.SET_FULL_SCREEN, payload: 'disable' }, + '*' + ); + }); + }); + + describe('getHeaders', () => { + it('should return JWT Bearer header when jwtToken is provided', () => { + const actions = createActions('jwt-token-123', null); + + const headers = actions.getHeaders(); + + expect(headers).toEqual({ + Authorization: 'Bearer jwt-token-123', + 'Content-type': 'application/json', + }); + }); + + it('should return X-Auth-Token header when only token is provided', () => { + const actions = createActions(null, 'api-token-456'); + + const headers = actions.getHeaders(); + + expect(headers).toEqual({ + 'X-Auth-Token': 'api-token-456', + 'Content-type': 'application/json', + }); + }); + + it('should prefer JWT token over API token', () => { + const actions = createActions('jwt-token', 'api-token'); + + const headers = actions.getHeaders(); + + expect(headers).toEqual({ + Authorization: 'Bearer jwt-token', + 'Content-type': 'application/json', + }); + }); + + it('should return only Content-type when no tokens are provided', () => { + const actions = createActions(null, null); + + const headers = actions.getHeaders(); + + expect(headers).toEqual({ + 'Content-type': 'application/json', + }); + }); + }); +}); diff --git a/src/context/__tests__/messageHandlers.test.ts b/src/context/__tests__/messageHandlers.test.ts index 4f7bc6a..796d1d3 100644 --- a/src/context/__tests__/messageHandlers.test.ts +++ b/src/context/__tests__/messageHandlers.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { handleInboundMessage } from '../messageHandlers'; -import { INBOUND_EVENTS } from '../constants'; +import { INBOUND_EVENTS, INBOUND_EVENTS_V2 } from '../constants'; import type { ReadyEvent } from '@/types'; describe('messageHandlers', () => { @@ -313,4 +313,312 @@ describe('messageHandlers', () => { expect(satisfiedEventsRef.current.has('receivedToken')).toBe(true); }); }); + + describe('V2 Events emission from V1 handlers', () => { + let postMessageSpy: ReturnType; + + beforeEach(() => { + postMessageSpy = vi.spyOn(window.parent, 'postMessage'); + }); + + afterEach(() => { + postMessageSpy.mockRestore(); + }); + + it('should emit v2:auth:token when receivedToken is processed', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + + handleInboundMessage( + INBOUND_EVENTS.RECEIVED_TOKEN, + 'test-token', + dispatch, + satisfiedEventsRef + ); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: INBOUND_EVENTS_V2.TOKEN, payload: 'test-token' }, + '*' + ); + }); + + it('should emit v2:auth:jwt when receivedJWTToken is processed', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + + handleInboundMessage( + INBOUND_EVENTS.RECEIVED_JWT_TOKEN, + 'jwt-token', + dispatch, + satisfiedEventsRef + ); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: INBOUND_EVENTS_V2.JWT, payload: 'jwt-token' }, + '*' + ); + }); + + it('should emit v2:dashboard:devices:selected when selectedDevice is processed', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + + handleInboundMessage( + INBOUND_EVENTS.SELECTED_DEVICE, + 'device-123', + dispatch, + satisfiedEventsRef + ); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + event: INBOUND_EVENTS_V2.SELECTED_DEVICES, + payload: [{ id: 'device-123' }], + }, + '*' + ); + }); + + it('should emit v2:dashboard:devices:selected when selectedDevices is processed', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + const devices = [{ id: 'dev1' }, { id: 'dev2' }]; + + handleInboundMessage( + INBOUND_EVENTS.SELECTED_DEVICES, + devices, + dispatch, + satisfiedEventsRef + ); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: INBOUND_EVENTS_V2.SELECTED_DEVICES, payload: devices }, + '*' + ); + }); + + it('should emit v2:dashboard:settings:daterange when selectedDashboardDateRange is processed', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + const dateRange = { startTime: 1000, endTime: 2000 }; + + handleInboundMessage( + INBOUND_EVENTS.SELECTED_DASHBOARD_DATE_RANGE, + dateRange, + dispatch, + satisfiedEventsRef + ); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: INBOUND_EVENTS_V2.SELECTED_DATE_RANGE, payload: dateRange }, + '*' + ); + }); + + it('should emit v2:dashboard:self when selectedDashboardObject is processed', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + const dashboardObject = { id: 'dash-1', name: 'Dashboard' }; + + handleInboundMessage( + INBOUND_EVENTS.SELECTED_DASHBOARD_OBJECT, + dashboardObject, + dispatch, + satisfiedEventsRef + ); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + event: INBOUND_EVENTS_V2.SELECTED_DASHBOARD_OBJECT, + payload: dashboardObject, + }, + '*' + ); + }); + + it('should emit v2:dashboard:settings:filters when selectedFilters is processed', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + const filters = [{ id: 'filter1', value: 'value1' }]; + + handleInboundMessage( + INBOUND_EVENTS.SELECTED_FILTERS, + filters, + dispatch, + satisfiedEventsRef + ); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: INBOUND_EVENTS_V2.SELECTED_FILTERS, payload: filters }, + '*' + ); + }); + + it('should emit v2:dashboard:settings:rt when isRealTimeActive is processed', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + + handleInboundMessage( + INBOUND_EVENTS.IS_REAL_TIME_ACTIVE, + true, + dispatch, + satisfiedEventsRef + ); + + expect(postMessageSpy).toHaveBeenCalledWith( + { event: INBOUND_EVENTS_V2.REALTIME_ACTIVE, payload: true }, + '*' + ); + }); + + it('should NOT emit V2 event when selectedDevice payload is invalid', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + + handleInboundMessage( + INBOUND_EVENTS.SELECTED_DEVICE, + null, + dispatch, + satisfiedEventsRef + ); + + expect(postMessageSpy).not.toHaveBeenCalled(); + }); + }); + + describe('V2 Events direct handlers', () => { + it('should handle v2:auth:token event', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + + handleInboundMessage( + INBOUND_EVENTS_V2.TOKEN, + 'v2-token', + dispatch, + satisfiedEventsRef + ); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'RECEIVED_TOKEN', + payload: 'v2-token', + }); + expect(satisfiedEventsRef.current.has('receivedToken')).toBe(true); + }); + + it('should handle v2:auth:jwt event', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + + handleInboundMessage( + INBOUND_EVENTS_V2.JWT, + 'v2-jwt-token', + dispatch, + satisfiedEventsRef + ); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'RECEIVED_JWT_TOKEN', + payload: 'v2-jwt-token', + }); + expect(satisfiedEventsRef.current.has('receivedJWTToken')).toBe(true); + }); + + it('should handle v2:dashboard:devices:selected event', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + const devices = [{ id: 'v2-dev1' }, { id: 'v2-dev2' }]; + + handleInboundMessage( + INBOUND_EVENTS_V2.SELECTED_DEVICES, + devices, + dispatch, + satisfiedEventsRef + ); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'SELECTED_DEVICES', + payload: devices, + }); + expect(satisfiedEventsRef.current.has('selectedDevices')).toBe(true); + }); + + it('should handle v2:dashboard:settings:daterange event', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + const dateRange = { startTime: 5000, endTime: 6000 }; + + handleInboundMessage( + INBOUND_EVENTS_V2.SELECTED_DATE_RANGE, + dateRange, + dispatch, + satisfiedEventsRef + ); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'SELECTED_DASHBOARD_DATE_RANGE', + payload: dateRange, + }); + expect(satisfiedEventsRef.current.has('selectedDashboardDateRange')).toBe( + true + ); + }); + + it('should handle v2:dashboard:self event', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + const dashboard = { id: 'v2-dash', name: 'V2 Dashboard' }; + + handleInboundMessage( + INBOUND_EVENTS_V2.SELECTED_DASHBOARD_OBJECT, + dashboard, + dispatch, + satisfiedEventsRef + ); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'SELECTED_DASHBOARD_OBJECT', + payload: dashboard, + }); + expect(satisfiedEventsRef.current.has('selectedDashboardObject')).toBe( + true + ); + }); + + it('should handle v2:dashboard:settings:filters event', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + const filters = [{ id: 'v2-filter', value: 'v2-value' }]; + + handleInboundMessage( + INBOUND_EVENTS_V2.SELECTED_FILTERS, + filters, + dispatch, + satisfiedEventsRef + ); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'SELECTED_FILTERS', + payload: filters, + }); + expect(satisfiedEventsRef.current.has('selectedFilters')).toBe(true); + }); + + it('should handle v2:dashboard:settings:rt event', () => { + const dispatch = vi.fn(); + const satisfiedEventsRef = { current: new Set() }; + + handleInboundMessage( + INBOUND_EVENTS_V2.REALTIME_ACTIVE, + false, + dispatch, + satisfiedEventsRef + ); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'REAL_TIME_STATUS', + payload: false, + }); + expect(satisfiedEventsRef.current.has('isRealTimeActive')).toBe(true); + }); + }); }); diff --git a/src/context/actions.ts b/src/context/actions.ts index 8627cd5..c7727c0 100644 --- a/src/context/actions.ts +++ b/src/context/actions.ts @@ -1,5 +1,5 @@ import type { DateRange, OutboundActions } from '@/types'; -import { OUTBOUND_EVENTS } from './constants'; +import { OUTBOUND_EVENTS, OUTBOUND_EVENTS_V2 } from './constants'; /** * Post message to parent window @@ -10,6 +10,18 @@ function postMessage(event: string, payload?: unknown): void { } } +/** + * Post both V1 and V2 events to parent window + */ +function postMessageWithV2( + eventV1: string, + eventV2: string, + payload?: unknown +): void { + postMessage(eventV1, payload); + postMessage(eventV2, payload); +} + /** * Generate authentication headers based on available tokens */ @@ -64,36 +76,65 @@ function validateDateRange(range: DateRange): boolean { /** * Action creators using object mapping + * Each action emits both V1 and V2 events for backward compatibility */ const actionCreators = { - setDashboardDevice: (deviceId: string) => - postMessage(OUTBOUND_EVENTS.SET_DASHBOARD_DEVICE, deviceId), + setDashboardDevice: (deviceId: string) => { + // Convert single device to array for V2 + postMessage(OUTBOUND_EVENTS.SET_DASHBOARD_DEVICE, deviceId); + postMessage(OUTBOUND_EVENTS_V2.SET_DASHBOARD_DEVICE, [{ id: deviceId }]); + }, - setDashboardMultipleDevices: (deviceIds: string[]) => - postMessage(OUTBOUND_EVENTS.SET_DASHBOARD_MULTIPLE_DEVICES, deviceIds), + setDashboardMultipleDevices: (deviceIds: string[]) => { + // Convert string array to Device array for V2 + const devices = deviceIds.map(id => ({ id })); + postMessage(OUTBOUND_EVENTS.SET_DASHBOARD_MULTIPLE_DEVICES, deviceIds); + postMessage(OUTBOUND_EVENTS_V2.SET_DASHBOARD_MULTIPLE_DEVICES, devices); + }, setDashboardDateRange: (range: DateRange) => { if (!validateDateRange(range)) return; - postMessage(OUTBOUND_EVENTS.SET_DASHBOARD_DATE_RANGE, range); + postMessageWithV2( + OUTBOUND_EVENTS.SET_DASHBOARD_DATE_RANGE, + OUTBOUND_EVENTS_V2.SET_DASHBOARD_DATE_RANGE, + range + ); }, - setDashboardLayer: (layerId: string) => - postMessage(OUTBOUND_EVENTS.SET_DASHBOARD_LAYER, layerId), + setDashboardLayer: (layerId: string) => { + // V2 doesn't have a direct equivalent for layer + postMessage(OUTBOUND_EVENTS.SET_DASHBOARD_LAYER, layerId); + }, - setRealTime: (rt: boolean) => postMessage(OUTBOUND_EVENTS.SET_REAL_TIME, rt), + setRealTime: (rt: boolean) => + postMessageWithV2( + OUTBOUND_EVENTS.SET_REAL_TIME, + OUTBOUND_EVENTS_V2.SET_REAL_TIME, + rt + ), - refreshDashboard: () => postMessage(OUTBOUND_EVENTS.REFRESH_DASHBOARD), + refreshDashboard: () => + postMessageWithV2( + OUTBOUND_EVENTS.REFRESH_DASHBOARD, + OUTBOUND_EVENTS_V2.REFRESH_DASHBOARD + ), openDrawer: (opts: { url: string; width: number }) => { const id = typeof window !== 'undefined' ? (window as unknown as Record).widgetId : 'react-widget'; - postMessage(OUTBOUND_EVENTS.OPEN_DRAWER, { drawerInfo: opts, id }); + const payload = { drawerInfo: opts, id }; + postMessage(OUTBOUND_EVENTS.OPEN_DRAWER, payload); + postMessage(OUTBOUND_EVENTS_V2.OPEN_DRAWER, payload); }, setFullScreen: (setting: 'toggle' | 'enable' | 'disable') => - postMessage(OUTBOUND_EVENTS.SET_FULL_SCREEN, setting), + postMessageWithV2( + OUTBOUND_EVENTS.SET_FULL_SCREEN, + OUTBOUND_EVENTS_V2.SET_FULL_SCREEN, + setting + ), }; /** diff --git a/src/context/constants.ts b/src/context/constants.ts index e666a85..21d5056 100644 --- a/src/context/constants.ts +++ b/src/context/constants.ts @@ -4,6 +4,8 @@ import type { ReadyEvent } from '@/types'; +// ==================== V1 Events (Legacy) ==================== + export const INBOUND_EVENTS = { RECEIVED_TOKEN: 'receivedToken', RECEIVED_JWT_TOKEN: 'receivedJWTToken', @@ -28,6 +30,34 @@ export const OUTBOUND_EVENTS = { SET_FULL_SCREEN: 'setFullScreen', } as const; +// ==================== V2 Events ==================== + +export const INBOUND_EVENTS_V2 = { + // Auth Events + TOKEN: 'v2:auth:token', + JWT: 'v2:auth:jwt', + + // Dashboard Events + DEVICES_ALL: 'v2:dashboard:devices:self', + SELECTED_DEVICES: 'v2:dashboard:devices:selected', + SELECTED_DATE_RANGE: 'v2:dashboard:settings:daterange', + REFRESH_DASHBOARD: 'v2:dashboard:settings:refreshed', + REALTIME_ACTIVE: 'v2:dashboard:settings:rt', + SELECTED_DASHBOARD_OBJECT: 'v2:dashboard:self', + SELECTED_FILTERS: 'v2:dashboard:settings:filters', +} as const; + +export const OUTBOUND_EVENTS_V2 = { + // Dashboard Events + SET_DASHBOARD_DEVICE: 'v2:dashboard:devices:selected', + SET_DASHBOARD_MULTIPLE_DEVICES: 'v2:dashboard:devices:selected', + SET_DASHBOARD_DATE_RANGE: 'v2:dashboard:settings:daterange', + SET_REAL_TIME: 'v2:dashboard:settings:rt', + REFRESH_DASHBOARD: 'v2:dashboard:settings:refreshed', + SET_FULL_SCREEN: 'v2:dashboard:settings:fullscreen', + OPEN_DRAWER: 'v2:dashboard:drawer:open', +} as const; + export const ACTION_TYPES = { RECEIVED_TOKEN: 'RECEIVED_TOKEN', RECEIVED_JWT_TOKEN: 'RECEIVED_JWT_TOKEN', @@ -41,6 +71,7 @@ export const ACTION_TYPES = { REAL_TIME_STATUS: 'REAL_TIME_STATUS', SET_READY: 'SET_READY', SET_WIDGET: 'SET_WIDGET', + SET_WIDGET_ID: 'SET_WIDGET_ID', } as const; export const DEFAULT_READY_EVENTS: ReadyEvent[] = ['receivedToken']; diff --git a/src/context/messageHandlers.ts b/src/context/messageHandlers.ts index af6ddec..dd69ce9 100644 --- a/src/context/messageHandlers.ts +++ b/src/context/messageHandlers.ts @@ -9,7 +9,17 @@ import type { FilterValue, ReadyEvent, } from '@/types'; -import { INBOUND_EVENTS, ACTION_TYPES } from './constants'; +import { INBOUND_EVENTS, INBOUND_EVENTS_V2, ACTION_TYPES } from './constants'; + +/** + * Post V2 event to parent window + * This is used to emit V2 events when V1 events are processed + */ +function emitV2Event(event: string, payload?: unknown): void { + if (typeof window !== 'undefined' && window.parent) { + window.parent.postMessage({ event, payload }, '*'); + } +} /** * Message handler function type @@ -24,9 +34,12 @@ type MessageHandler = ( * Individual message handlers */ const messageHandlers: Record = { + // ==================== V1 Auth Events ==================== [INBOUND_EVENTS.RECEIVED_TOKEN]: (payload, dispatch, satisfiedEventsRef) => { dispatch({ type: ACTION_TYPES.RECEIVED_TOKEN, payload: payload as string }); satisfiedEventsRef.current.add('receivedToken'); + // Emit V2 event + emitV2Event(INBOUND_EVENTS_V2.TOKEN, payload); }, [INBOUND_EVENTS.RECEIVED_JWT_TOKEN]: ( @@ -39,8 +52,11 @@ const messageHandlers: Record = { payload: payload as string, }); satisfiedEventsRef.current.add('receivedJWTToken'); + // Emit V2 event + emitV2Event(INBOUND_EVENTS_V2.JWT, payload); }, + // ==================== V1 Dashboard Events ==================== [INBOUND_EVENTS.SELECTED_DEVICE]: (payload, dispatch, satisfiedEventsRef) => { let validatedPayload: Device | null = null; if (typeof payload === 'string') { @@ -50,6 +66,10 @@ const messageHandlers: Record = { dispatch({ type: ACTION_TYPES.SELECTED_DEVICE, payload: validatedPayload }); satisfiedEventsRef.current.add('selectedDevice'); + // Emit V2 event - convert single device to array format + if (validatedPayload) { + emitV2Event(INBOUND_EVENTS_V2.SELECTED_DEVICES, [validatedPayload]); + } }, [INBOUND_EVENTS.SELECTED_DEVICES]: ( @@ -62,6 +82,8 @@ const messageHandlers: Record = { payload: payload as Device[] | null, }); satisfiedEventsRef.current.add('selectedDevices'); + // Emit V2 event + emitV2Event(INBOUND_EVENTS_V2.SELECTED_DEVICES, payload); }, [INBOUND_EVENTS.SELECTED_DASHBOARD_DATE_RANGE]: ( @@ -74,6 +96,8 @@ const messageHandlers: Record = { payload: payload as DateRange | null, }); satisfiedEventsRef.current.add('selectedDashboardDateRange'); + // Emit V2 event + emitV2Event(INBOUND_EVENTS_V2.SELECTED_DATE_RANGE, payload); }, [INBOUND_EVENTS.SELECTED_DASHBOARD_OBJECT]: ( @@ -86,6 +110,8 @@ const messageHandlers: Record = { payload: payload as DashboardObject | null, }); satisfiedEventsRef.current.add('selectedDashboardObject'); + // Emit V2 event + emitV2Event(INBOUND_EVENTS_V2.SELECTED_DASHBOARD_OBJECT, payload); }, [INBOUND_EVENTS.SELECTED_DEVICE_OBJECT]: ( @@ -98,6 +124,7 @@ const messageHandlers: Record = { payload: payload as DeviceObject | null, }); satisfiedEventsRef.current.add('selectedDeviceObject'); + // Note: No V2 equivalent for SELECTED_DEVICE_OBJECT }, [INBOUND_EVENTS.SELECTED_DEVICE_OBJECTS]: ( @@ -110,6 +137,7 @@ const messageHandlers: Record = { payload: payload as DeviceObject[] | null, }); satisfiedEventsRef.current.add('selectedDeviceObjects'); + // Note: No V2 equivalent for SELECTED_DEVICE_OBJECTS }, [INBOUND_EVENTS.SELECTED_FILTERS]: ( @@ -122,12 +150,91 @@ const messageHandlers: Record = { payload: payload as FilterValue[] | null, }); satisfiedEventsRef.current.add('selectedFilters'); + // Emit V2 event + emitV2Event(INBOUND_EVENTS_V2.SELECTED_FILTERS, payload); }, [INBOUND_EVENTS.IS_REAL_TIME_ACTIVE]: ( payload, dispatch, satisfiedEventsRef + ) => { + dispatch({ + type: ACTION_TYPES.REAL_TIME_STATUS, + payload: payload as boolean | null, + }); + satisfiedEventsRef.current.add('isRealTimeActive'); + // Emit V2 event + emitV2Event(INBOUND_EVENTS_V2.REALTIME_ACTIVE, payload); + }, + + // ==================== V2 Events (direct handlers) ==================== + // These allow the system to also receive V2 events directly + [INBOUND_EVENTS_V2.TOKEN]: (payload, dispatch, satisfiedEventsRef) => { + dispatch({ type: ACTION_TYPES.RECEIVED_TOKEN, payload: payload as string }); + satisfiedEventsRef.current.add('receivedToken'); + }, + + [INBOUND_EVENTS_V2.JWT]: (payload, dispatch, satisfiedEventsRef) => { + dispatch({ + type: ACTION_TYPES.RECEIVED_JWT_TOKEN, + payload: payload as string, + }); + satisfiedEventsRef.current.add('receivedJWTToken'); + }, + + [INBOUND_EVENTS_V2.SELECTED_DEVICES]: ( + payload, + dispatch, + satisfiedEventsRef + ) => { + dispatch({ + type: ACTION_TYPES.SELECTED_DEVICES, + payload: payload as Device[] | null, + }); + satisfiedEventsRef.current.add('selectedDevices'); + }, + + [INBOUND_EVENTS_V2.SELECTED_DATE_RANGE]: ( + payload, + dispatch, + satisfiedEventsRef + ) => { + dispatch({ + type: ACTION_TYPES.SELECTED_DASHBOARD_DATE_RANGE, + payload: payload as DateRange | null, + }); + satisfiedEventsRef.current.add('selectedDashboardDateRange'); + }, + + [INBOUND_EVENTS_V2.SELECTED_DASHBOARD_OBJECT]: ( + payload, + dispatch, + satisfiedEventsRef + ) => { + dispatch({ + type: ACTION_TYPES.SELECTED_DASHBOARD_OBJECT, + payload: payload as DashboardObject | null, + }); + satisfiedEventsRef.current.add('selectedDashboardObject'); + }, + + [INBOUND_EVENTS_V2.SELECTED_FILTERS]: ( + payload, + dispatch, + satisfiedEventsRef + ) => { + dispatch({ + type: ACTION_TYPES.SELECTED_FILTERS, + payload: payload as FilterValue[] | null, + }); + satisfiedEventsRef.current.add('selectedFilters'); + }, + + [INBOUND_EVENTS_V2.REALTIME_ACTIVE]: ( + payload, + dispatch, + satisfiedEventsRef ) => { dispatch({ type: ACTION_TYPES.REAL_TIME_STATUS, diff --git a/src/context/ubidots.tsx b/src/context/ubidots.tsx index 9d39d35..471625f 100644 --- a/src/context/ubidots.tsx +++ b/src/context/ubidots.tsx @@ -18,7 +18,7 @@ import type { UbidotsState, UbidotsActions, ReadyEvent } from '@/types'; import { initialState, ubidotsReducer } from './UbidotsReducer'; import { handleInboundMessage, checkReadyState } from './messageHandlers'; import { createActions } from './actions'; -import { DEFAULT_READY_EVENTS } from './constants'; +import { ACTION_TYPES, DEFAULT_READY_EVENTS } from './constants'; export interface UbidotsContextValue { state: UbidotsState; @@ -50,6 +50,7 @@ export interface UbidotsProviderProps { readyEvents?: ReadyEvent[]; validateOrigin?: (origin: string) => boolean; initialStateOverride?: Partial; + widgetId?: string; } export function UbidotsProvider({ @@ -58,6 +59,7 @@ export function UbidotsProvider({ readyEvents = DEFAULT_READY_EVENTS, validateOrigin, initialStateOverride, + widgetId, }: UbidotsProviderProps) { const [state, dispatch] = useReducer(ubidotsReducer, { ...initialState, @@ -66,6 +68,14 @@ export function UbidotsProvider({ const readyRef = useRef(false); const satisfiedEventsRef = useRef(new Set()); + // Set widgetId on window and in state + useEffect(() => { + if (widgetId) { + (window as unknown as Record).widgetId = widgetId; + dispatch({ type: ACTION_TYPES.SET_WIDGET_ID, payload: widgetId }); + } + }, [widgetId]); + const isOriginValid = useCallback( (origin: string) => (validateOrigin ? validateOrigin(origin) : true), [validateOrigin] diff --git a/src/hooks/__tests__/useUbidotsSelections.test.tsx b/src/hooks/__tests__/useUbidotsSelections.test.tsx index 615dbcb..2195809 100644 --- a/src/hooks/__tests__/useUbidotsSelections.test.tsx +++ b/src/hooks/__tests__/useUbidotsSelections.test.tsx @@ -14,6 +14,7 @@ import { useUbidotsWidget, useUbidotsSelectedDeviceObjects, useUbidotsSelectedFilters, + useUbidotsWidgetId, } from '@/hooks/useUbidotsSelections'; import type { Device, @@ -395,6 +396,52 @@ describe('useUbidotsSelections', () => { ]); }); }); + + it('useUbidotsWidgetId returns widgetId from provider prop', async () => { + let capturedValue: string | null = null; + + function Tester() { + return ( + (capturedValue = v)} + /> + ); + } + + render( + + + + ); + + await waitFor(() => { + expect(capturedValue).toBe('test-widget-123'); + }); + }); + + it('useUbidotsWidgetId returns null when widgetId is not provided', async () => { + let capturedValue: string | null = 'initial'; + + function Tester() { + return ( + (capturedValue = v)} + /> + ); + } + + render( + + + + ); + + await waitFor(() => { + expect(capturedValue).toBeNull(); + }); + }); }); describe('Hooks update when context state changes', () => { diff --git a/src/hooks/__tests__/useWidgetEvents.test.tsx b/src/hooks/__tests__/useWidgetEvents.test.tsx new file mode 100644 index 0000000..0d929bf --- /dev/null +++ b/src/hooks/__tests__/useWidgetEvents.test.tsx @@ -0,0 +1,597 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, act } from '@testing-library/react'; +import React from 'react'; +import { UbidotsProvider } from '@/context/ubidots'; +import { useWidgetEvents } from '@/hooks/useWidgetEvents'; + +describe('useWidgetEvents', () => { + let postMessageSpy: ReturnType; + + beforeEach(() => { + postMessageSpy = vi.spyOn(window.parent, 'postMessage'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + postMessageSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + describe('emitWidgetEvent', () => { + it('should emit widget event with correct format when widgetId is provided via param', async () => { + let emitFn: ((event: string, payload?: unknown) => void) | null = null; + + function TestComponent() { + const { emitWidgetEvent } = useWidgetEvents('widget-123'); + emitFn = emitWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(emitFn).not.toBeNull(); + }); + + act(() => { + emitFn!('custom-event', { data: 'test' }); + }); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + event: 'v2:widget:custom-event:widget-123', + payload: { data: 'test' }, + }, + '*' + ); + }); + + it('should emit widget event with widgetId from provider', async () => { + let emitFn: ((event: string, payload?: unknown) => void) | null = null; + + function TestComponent() { + const { emitWidgetEvent } = useWidgetEvents(); + emitFn = emitWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(emitFn).not.toBeNull(); + }); + + act(() => { + emitFn!('test-event', { value: 42 }); + }); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + event: 'v2:widget:test-event:provider-widget', + payload: { value: 42 }, + }, + '*' + ); + }); + + it('should warn and not emit when widgetId is not defined', async () => { + let emitFn: ((event: string, payload?: unknown) => void) | null = null; + const consoleWarnSpy = vi.spyOn(console, 'warn'); + + function TestComponent() { + const { emitWidgetEvent } = useWidgetEvents(); + emitFn = emitWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(emitFn).not.toBeNull(); + }); + + // Clear any previous calls (like from ready event) + postMessageSpy.mockClear(); + + act(() => { + emitFn!('some-event', { data: 'test' }); + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Cannot emit widget event: widgetId is not defined' + ); + expect(postMessageSpy).not.toHaveBeenCalled(); + }); + + it('should emit event without payload', async () => { + let emitFn: ((event: string, payload?: unknown) => void) | null = null; + + function TestComponent() { + const { emitWidgetEvent } = useWidgetEvents('widget-abc'); + emitFn = emitWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(emitFn).not.toBeNull(); + }); + + act(() => { + emitFn!('ping'); + }); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + event: 'v2:widget:ping:widget-abc', + payload: undefined, + }, + '*' + ); + }); + }); + + describe('onWidgetEvent', () => { + it('should register and invoke callback when matching event is received', async () => { + const callback = vi.fn(); + let onEventFn: + | ((event: string, cb: (payload: unknown) => void) => () => void) + | null = null; + + function TestComponent() { + const { onWidgetEvent } = useWidgetEvents('widget-123'); + onEventFn = onWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(onEventFn).not.toBeNull(); + }); + + act(() => { + onEventFn!('v2:widget:custom:other-widget', callback); + }); + + // Simulate receiving a widget event + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + event: 'v2:widget:custom:other-widget', + payload: { test: 'data' }, + }, + }) + ); + }); + + expect(callback).toHaveBeenCalledWith({ test: 'data' }); + }); + + it('should return cleanup function that removes listener', async () => { + const callback = vi.fn(); + let onEventFn: + | ((event: string, cb: (payload: unknown) => void) => () => void) + | null = null; + + function TestComponent() { + const { onWidgetEvent } = useWidgetEvents('widget-123'); + onEventFn = onWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(onEventFn).not.toBeNull(); + }); + + let cleanup: (() => void) | null = null; + act(() => { + cleanup = onEventFn!('v2:widget:test:widget-456', callback); + }); + + // Call cleanup + act(() => { + cleanup!(); + }); + + // Simulate receiving the event after cleanup + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + event: 'v2:widget:test:widget-456', + payload: { test: 'after-cleanup' }, + }, + }) + ); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should invoke wildcard listeners for any widget event', async () => { + const wildcardCallback = vi.fn(); + let onEventFn: + | ((event: string, cb: (payload: unknown) => void) => () => void) + | null = null; + + function TestComponent() { + const { onWidgetEvent } = useWidgetEvents('widget-123'); + onEventFn = onWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(onEventFn).not.toBeNull(); + }); + + act(() => { + onEventFn!('*', wildcardCallback); + }); + + // Simulate receiving any widget event + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + event: 'v2:widget:any-event:any-widget', + payload: { wildcard: true }, + }, + }) + ); + }); + + expect(wildcardCallback).toHaveBeenCalledWith({ wildcard: true }); + }); + }); + + describe('onAnyWidgetEvent', () => { + it('should invoke callback for any v2:widget event with event name and payload', async () => { + const callback = vi.fn(); + let onAnyFn: + | ((cb: (event: string, payload: unknown) => void) => () => void) + | null = null; + + function TestComponent() { + const { onAnyWidgetEvent } = useWidgetEvents('widget-123'); + onAnyFn = onAnyWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(onAnyFn).not.toBeNull(); + }); + + let cleanup: (() => void) | null = null; + act(() => { + cleanup = onAnyFn!(callback); + }); + + // Simulate receiving a widget event + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + event: 'v2:widget:monitoring:widget-xyz', + payload: { monitoring: true }, + }, + }) + ); + }); + + expect(callback).toHaveBeenCalledWith('v2:widget:monitoring:widget-xyz', { + monitoring: true, + }); + + // Cleanup + act(() => { + cleanup!(); + }); + }); + + it('should NOT invoke callback for non-widget events', async () => { + const callback = vi.fn(); + let onAnyFn: + | ((cb: (event: string, payload: unknown) => void) => () => void) + | null = null; + + function TestComponent() { + const { onAnyWidgetEvent } = useWidgetEvents('widget-123'); + onAnyFn = onAnyWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(onAnyFn).not.toBeNull(); + }); + + act(() => { + onAnyFn!(callback); + }); + + // Simulate receiving a non-widget event + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + event: 'receivedToken', + payload: 'some-token', + }, + }) + ); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('widgetId return value', () => { + it('should return widgetId passed as parameter', async () => { + let returnedWidgetId: string | null | undefined = undefined; + + function TestComponent() { + const { widgetId } = useWidgetEvents('param-widget'); + returnedWidgetId = widgetId; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(returnedWidgetId).toBe('param-widget'); + }); + }); + + it('should return widgetId from context when no param provided', async () => { + let returnedWidgetId: string | null | undefined = undefined; + + function TestComponent() { + const { widgetId } = useWidgetEvents(); + returnedWidgetId = widgetId; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(returnedWidgetId).toBe('context-widget'); + }); + }); + + it('should prefer param widgetId over context widgetId', async () => { + let returnedWidgetId: string | null | undefined = undefined; + + function TestComponent() { + const { widgetId } = useWidgetEvents('param-takes-priority'); + returnedWidgetId = widgetId; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(returnedWidgetId).toBe('param-takes-priority'); + }); + }); + }); + + describe('lifecycle events', () => { + it('should emit ready event when widget is ready', async () => { + function TestComponent() { + useWidgetEvents('lifecycle-widget'); + return null; + } + + render( + + + + ); + + // Wait for ready event to be emitted + await waitFor(() => { + const readyCall = postMessageSpy.mock.calls.find( + call => + call[0] && + typeof call[0] === 'object' && + 'event' in call[0] && + call[0].event === 'v2:widget:ready:lifecycle-widget' + ); + expect(readyCall).toBeDefined(); + }); + + const readyCall = postMessageSpy.mock.calls.find( + call => + call[0] && + typeof call[0] === 'object' && + 'event' in call[0] && + call[0].event === 'v2:widget:ready:lifecycle-widget' + ); + + expect(readyCall![0]).toMatchObject({ + event: 'v2:widget:ready:lifecycle-widget', + payload: expect.objectContaining({ + widgetId: 'lifecycle-widget', + timestamp: expect.any(Number), + }), + }); + }); + }); + + describe('message event filtering', () => { + it('should ignore non-widget events in the internal listener', async () => { + const callback = vi.fn(); + let onEventFn: + | ((event: string, cb: (payload: unknown) => void) => () => void) + | null = null; + + function TestComponent() { + const { onWidgetEvent } = useWidgetEvents('widget-123'); + onEventFn = onWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(onEventFn).not.toBeNull(); + }); + + act(() => { + onEventFn!('receivedToken', callback); + }); + + // Simulate receiving a V1 event (not a widget event) + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + event: 'receivedToken', + payload: 'token-value', + }, + }) + ); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should handle messages without event property', async () => { + const callback = vi.fn(); + let onEventFn: + | ((event: string, cb: (payload: unknown) => void) => () => void) + | null = null; + + function TestComponent() { + const { onWidgetEvent } = useWidgetEvents('widget-123'); + onEventFn = onWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(onEventFn).not.toBeNull(); + }); + + act(() => { + onEventFn!('*', callback); + }); + + // Simulate receiving a message without event property + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { someOtherProperty: 'value' }, + }) + ); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should handle null message data', async () => { + const callback = vi.fn(); + let onEventFn: + | ((event: string, cb: (payload: unknown) => void) => () => void) + | null = null; + + function TestComponent() { + const { onWidgetEvent } = useWidgetEvents('widget-123'); + onEventFn = onWidgetEvent; + return null; + } + + render( + + + + ); + + await waitFor(() => { + expect(onEventFn).not.toBeNull(); + }); + + act(() => { + onEventFn!('*', callback); + }); + + // Simulate receiving a message with null data + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: null, + }) + ); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 86b6c44..cb17230 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,3 +2,4 @@ export * from './useUbidotsReady'; export * from './useUbidotsSelections'; export * from './useUbidotsActions'; export * from './useUbidotsAPI'; +export * from './useWidgetEvents'; diff --git a/src/hooks/useUbidotsSelections.ts b/src/hooks/useUbidotsSelections.ts index d7501dc..da361ca 100644 --- a/src/hooks/useUbidotsSelections.ts +++ b/src/hooks/useUbidotsSelections.ts @@ -54,3 +54,8 @@ export function useUbidotsSelectedFilters() { const { state } = useUbidots(); return state.selectedFilters; } + +export function useUbidotsWidgetId() { + const { state } = useUbidots(); + return state.widgetId; +} diff --git a/src/hooks/useWidgetEvents.ts b/src/hooks/useWidgetEvents.ts new file mode 100644 index 0000000..2da2a5b --- /dev/null +++ b/src/hooks/useWidgetEvents.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useUbidots } from '../context/ubidots'; +import { useUbidotsWidgetId } from './useUbidotsSelections'; + +/** + * Hook for emitting and listening to widget-specific events + * + * Widget events follow the pattern: v2:widget:: + * This allows multiple widgets to coexist with isolated event namespaces + */ +export function useWidgetEvents(widgetIdParam?: string) { + const { state } = useUbidots(); + const contextWidgetId = useUbidotsWidgetId(); + const widgetId = widgetIdParam || contextWidgetId; + const listenersRef = useRef void>>>( + new Map() + ); + + /** + * Emit a widget-specific event + * + * @param event - The event name (without the v2:widget prefix) + * @param payload - The event payload + */ + const emitWidgetEvent = useCallback( + (event: string, payload?: unknown) => { + if (!widgetId) { + console.warn('Cannot emit widget event: widgetId is not defined'); + return; + } + + const eventName = `v2:widget:${event}:${widgetId}`; + + // Emit to parent window + window.parent.postMessage( + { + event: eventName, + payload, + }, + '*' + ); + }, + [widgetId] + ); + + /** + * Listen to widget-specific events from other widgets + * + * @param event - The event name pattern to listen for + * @param callback - The callback to invoke when the event is received + */ + const onWidgetEvent = useCallback( + (event: string, callback: (payload: unknown) => void) => { + if (!listenersRef.current.has(event)) { + listenersRef.current.set(event, new Set()); + } + listenersRef.current.get(event)?.add(callback); + + // Return cleanup function + return () => { + listenersRef.current.get(event)?.delete(callback); + }; + }, + [] + ); + + /** + * Listen to all widget events (for monitoring/debugging) + */ + const onAnyWidgetEvent = useCallback( + (callback: (event: string, payload: unknown) => void) => { + const handleMessage = (ev: MessageEvent) => { + const { event, payload } = (ev.data || {}) as { + event?: string; + payload?: unknown; + }; + + if (event && event.startsWith('v2:widget:')) { + callback(event, payload); + } + }; + + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, + [] + ); + + // Set up message listener for widget events + useEffect(() => { + const handleMessage = (ev: MessageEvent) => { + const { event, payload } = (ev.data || {}) as { + event?: string; + payload?: unknown; + }; + + if (!event || !event.startsWith('v2:widget:')) { + return; + } + + // Notify all listeners for this specific event + listenersRef.current.get(event)?.forEach(callback => { + callback(payload); + }); + + // Also notify wildcard listeners + listenersRef.current.get('*')?.forEach(callback => { + callback(payload); + }); + }; + + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, []); + + // Emit lifecycle events + useEffect(() => { + if (widgetId && state.ready) { + emitWidgetEvent('ready', { widgetId, timestamp: Date.now() }); + } + }, [widgetId, state.ready, emitWidgetEvent]); + + return { + emitWidgetEvent, + onWidgetEvent, + onAnyWidgetEvent, + widgetId, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index bfa4af3..e921516 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ -export type ReadyEvent = +// V1 Ready Events (legacy) +export type ReadyEventV1 = | 'receivedToken' | 'receivedJWTToken' | 'selectedDevice' @@ -10,6 +11,21 @@ export type ReadyEvent = | 'selectedFilters' | 'isRealTimeActive'; +// V2 Ready Events +export type ReadyEventV2 = + | 'v2:auth:token' + | 'v2:auth:jwt' + | 'v2:dashboard:devices:self' + | 'v2:dashboard:devices:selected' + | 'v2:dashboard:settings:daterange' + | 'v2:dashboard:settings:refreshed' + | 'v2:dashboard:settings:rt' + | 'v2:dashboard:self' + | 'v2:dashboard:settings:filters'; + +// Combined type for backward compatibility +export type ReadyEvent = ReadyEventV1 | ReadyEventV2; + export interface Device { id: string; name?: string; @@ -58,6 +74,7 @@ export interface UbidotsState { selectedFilters: FilterValue[] | null; realTime: boolean | null; widget: WidgetInfo | null; + widgetId: string | null; } export type UbidotsAction = @@ -72,7 +89,8 @@ export type UbidotsAction = | { type: 'SELECTED_FILTERS'; payload: FilterValue[] | null } | { type: 'REAL_TIME_STATUS'; payload: boolean | null } | { type: 'SET_READY'; payload: boolean } - | { type: 'SET_WIDGET'; payload: WidgetInfo | null }; + | { type: 'SET_WIDGET'; payload: WidgetInfo | null } + | { type: 'SET_WIDGET_ID'; payload: string | null }; export interface OutboundActions { setDashboardDevice: (deviceId: string) => void; diff --git a/vite.examples.config.ts b/vite.examples.config.ts new file mode 100644 index 0000000..89ad2a6 --- /dev/null +++ b/vite.examples.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import path from 'path'; + +export default defineConfig({ + plugins: [ + react({ + jsxRuntime: 'classic', + }), + tsconfigPaths(), + ], + root: 'examples/dev', + publicDir: '../../public', + resolve: { + alias: { + '@ubidots/react-html-canvas': path.resolve(__dirname, './src/index.ts'), + }, + }, + server: { + port: 3000, + open: true, + }, +});