An open API service indexing awesome lists of open source software.

https://github.com/smnandre/stimulus-stream-actions

Handle Turbo Stream actions in your Stimulus controllers (with custom actions too!)
https://github.com/smnandre/stimulus-stream-actions

actions fluffy stimulus stimulus-controller symfony turbo turbo-stream ux

Last synced: 7 months ago
JSON representation

Handle Turbo Stream actions in your Stimulus controllers (with custom actions too!)

Awesome Lists containing this project

README

          

# Stimulus Stream Actions

> [!TIP]
> Stop polluting the global `StreamActions` object! Stimulus StreamActions lets you handle custom Turbo Stream actions in a clean, controller-scoped way with automatic cleanup.

## The Problem with Global Stream Actions

Custom Turbo Stream actions typically require **global registration**, creating pollution and maintenance headaches:

```javascript
import { StreamActions } from "@hotwired/turbo";

// Global pollution - affects entire application
StreamActions.closeModal = function() { /* ... */ }
StreamActions.updateCart = function() { /* ... */ }
StreamActions.showNotification = function() { /* ... */ }
```

**Problems:**
- **Global namespace pollution** - All actions share the same space
- **No automatic cleanup** - Actions persist even when controllers disconnect
- **Testing difficulties** - Hard to mock and isolate individual actions
- **No scoping** - Actions can't be controller-specific

## The Solution: Controller-Scoped Actions

Scope stream actions directly to your Stimulus controllers with automatic lifecycle management:

```javascript
// Clean, scoped, automatically managed
static streamActions = {
'close_modal': 'closeModal',
'update_cart': 'updateCart',
'show_notification': 'showNotification'
}
```

## Why This Approach is Better

### 🎯 **Real-World Example: E-commerce Cart**

**Before (Global):**
```javascript
// Somewhere in your app
StreamActions.updateCartCount = function(event) {
// Which cart? Which controller? Global state confusion.
document.querySelector('#cart-count').textContent = event.target.getAttribute('count');
}
```

**After (Controller-Scoped):**
```javascript
export default class CartController extends Controller {
static streamActions = {
'update_cart_count': 'updateCount',
'highlight_cart': 'highlightCart'
};

updateCount(streamData) {
// Scoped to THIS cart controller instance
const count = streamData.get('count');
this.element.querySelector('[data-cart-count]').textContent = count;
}

highlightCart(streamData) {
// Multiple cart controllers can coexist independently
const duration = streamData.getNumber('duration', 2000);
this.element.classList.add('cart-updated');
setTimeout(() => this.element.classList.remove('cart-updated'), duration);
}
}
```

### ✨ **Key Benefits**

- **No Global Pollution** - Actions are scoped to specific controllers
- **Auto Cleanup** - Actions automatically removed when controller disconnects
- **Better Testing** - Each controller can be tested in isolation
- **Type Safety** - Full TypeScript support with proper typing
- **Zero Build** - No decorators or extra tooling required
- **Controller Context** - Access to `this.element`, targets, values, etc.

## Installation

```bash
npm install @smnandre/stimulus-stream-actions
```

Or via CDN:
```javascript
import { useStreamActions } from 'https://cdn.jsdelivr.net/npm/@smnandre/stimulus-stream-actions@latest';
```

## Basic Usage

### 1. Define Stream Actions on Your Controller

```javascript
import { Controller } from '@hotwired/stimulus';
import { useStreamActions } from '@smnandre/stimulus-stream-actions';

export default class NotificationController extends Controller {
static streamActions = {
'show_notification': 'showNotification',
'hide_all_notifications': 'hideAll'
};

initialize() {
useStreamActions(this);
}

showNotification(streamData) {
const message = streamData.get('message');
const type = streamData.get('type', 'info'); // with fallback

// Use controller context - this.element, this.targets, etc.
const notification = this.element.querySelector('[data-notification-template]').cloneNode(true);
notification.textContent = message;
notification.className = `notification notification--${type}`;
this.element.appendChild(notification);
}

hideAll(streamData) {
// Work with multiple elements using controller's element as scope
this.element.querySelectorAll('.notification').forEach(notification => {
notification.remove();
});
}
}
```

### 2. Server Response (Any Backend)

**HTML Response:**
```html

```

**The stream elements can be generated by any backend framework that supports Turbo Streams.**

## Advanced Configuration

Add complete controller context and configuration options:

```javascript
import { Controller } from '@hotwired/stimulus';
import { useStreamActions } from '@smnandre/stimulus-stream-actions';

export default class ModalController extends Controller {
static streamActions = {
// Simple string mapping
'close_modal': 'closeModal',

// Advanced configuration with options
'update_content': {
method: 'updateContent',
preventDefault: false // Allow both custom and default Turbo behavior
},

// Another simple mapping
'highlight_modal': 'highlightModal'
};

initialize() {
useStreamActions(this);
}

closeModal(streamData) {
const modalId = streamData.get('modal-id');

// Close specific modal or all modals in this controller's scope
if (modalId) {
this.element.querySelector(`#${modalId}`)?.remove();
} else {
this.element.querySelectorAll('[data-modal]').forEach(modal => modal.remove());
}
}

updateContent(streamData) {
const content = streamData.content;
// Update all content areas within this controller
this.element.querySelectorAll('[data-modal-content]').forEach(area => {
area.innerHTML = content;
});
}

highlightModal(streamData) {
const duration = streamData.getNumber('duration', 2000);

// Highlight all modals in this controller's scope
this.element.querySelectorAll('[data-modal]').forEach(modal => {
modal.classList.add('modal--highlighted');
setTimeout(() => modal.classList.remove('modal--highlighted'), duration);
});
}
}
```

## Dynamic Stream Actions

For scenarios where actions depend on controller state or values:

```javascript
import { Controller } from '@hotwired/stimulus';
import { useCustomStreamActions } from '@smnandre/stimulus-stream-actions';

export default class DynamicController extends Controller {
static values = {
animated: Boolean,
mode: String
};

initialize() {
// Choose actions based on controller configuration
const actions = this.animatedValue ? {
'remove_item': 'animatedRemove',
'add_item': 'animatedAdd'
} : {
'remove_item': 'instantRemove',
'add_item': 'instantAdd'
};

// Add mode-specific actions
if (this.modeValue === 'admin') {
actions['bulk_delete'] = 'bulkDelete';
actions['bulk_update'] = 'bulkUpdate';
}

useCustomStreamActions(this, actions);
}

animatedRemove(streamData) {
const itemId = streamData.get('item-id');
const duration = streamData.getNumber('duration', 300);
const items = this.element.querySelectorAll(`[data-item-id="${itemId}"]`);

items.forEach(item => {
item.style.transition = `opacity ${duration}ms ease-out`;
item.style.opacity = '0';
setTimeout(() => item.remove(), duration);
});
}

instantRemove(streamData) {
const itemId = streamData.get('item-id');
this.element.querySelectorAll(`[data-item-id="${itemId}"]`).forEach(item => {
item.remove();
});
}

bulkDelete(streamData) {
const selector = streamData.get('selector');
this.element.querySelectorAll(selector).forEach(item => item.remove());
}
}
```

## Real-World Examples

### Multi-Tab Interface Updates

```javascript
export default class TabsController extends Controller {
static targets = ['tab', 'panel'];
static streamActions = {
'activate_tab': 'activateTab',
'update_tab_content': 'updateTabContent',
'add_notification_badge': 'addBadge'
};

activateTab(streamData) {
const tabId = streamData.get('tab-id');

// Deactivate all tabs in this controller
this.tabTargets.forEach(tab => tab.classList.remove('active'));
this.panelTargets.forEach(panel => panel.classList.remove('active'));

// Activate specific tab
const activeTab = this.element.querySelector(`[data-tab-id="${tabId}"]`);
const activePanel = this.element.querySelector(`[data-panel-id="${tabId}"]`);

activeTab?.classList.add('active');
activePanel?.classList.add('active');
}

updateTabContent(streamData) {
const tabId = streamData.get('tab-id');
const content = streamData.content;

const panel = this.element.querySelector(`[data-panel-id="${tabId}"]`);
if (panel) panel.innerHTML = content;
}

addBadge(streamData) {
const tabId = streamData.get('tab-id');
const count = streamData.getNumber('count', 0);

const tab = this.element.querySelector(`[data-tab-id="${tabId}"]`);
if (tab) {
// Remove existing badge
tab.querySelectorAll('.badge').forEach(badge => badge.remove());

// Add new badge
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = count.toString();
tab.appendChild(badge);
}
}
}
```

## API Reference

### `useStreamActions(controller)`
Enables stream actions defined in the static `streamActions` property.

```javascript
static streamActions = {
'action_name': 'methodName'
};
```

### `useCustomStreamActions(controller, actions)`
Programmatically registers stream actions for dynamic scenarios.

```javascript
useCustomStreamActions(this, {
'dynamic_action': 'handleDynamicAction'
});
```

### Stream Action Configuration Schema

```typescript
type StreamActionConfig = string | {
method: string; // Controller method name
preventDefault?: boolean; // Prevent default Turbo behavior (default: true)
};

type StreamActionMap = Record;
```

### Handler Method Signature

```javascript
handlerMethod(streamData) {
// streamData: Enhanced object with easy attribute access

// Easy attribute access
const value = streamData.get('custom-attribute');
const withFallback = streamData.get('optional-attr', 'default');

// Typed attribute access
const count = streamData.getNumber('count', 0);
const enabled = streamData.getBoolean('enabled');
const config = streamData.getJSON('config', {});

// Access stream content and target
const content = streamData.content;
const target = streamData.target;

// All attributes as object
const allAttrs = streamData.attributes;

// Original event if needed
const originalEvent = streamData.event;

// Use controller context
this.element.querySelector('...');
this.targets;
this.values;
}
```

## How It Works

1. **Registration**: Controllers register with a global `StreamActionRegistry` on connect
2. **Event Interception**: The registry listens for `turbo:before-stream-render` events
3. **Action Routing**: When a custom action is detected, it routes to the appropriate controller method
4. **Cleanup**: Controllers automatically unregister when they disconnect

**Scope**: Actions trigger for **all** `` elements in the DOM. The `turbo:before-stream-render` event bubbles to the document level, so any controller can handle any stream action.

## Testing

```bash
npm install
npm test # Unit tests with Vitest
npm run test:e2e # End-to-end tests with Playwright
```

## TypeScript Support

Full TypeScript support with proper type definitions:

```typescript
import { Controller } from '@hotwired/stimulus';
import { useStreamActions, useCustomStreamActions } from '@smnandre/stimulus-stream-actions';

export default class MyController extends Controller {
static streamActions = {
'my_action': 'handleMyAction'
} as const;

initialize(): void {
useStreamActions(this);
}

handleMyAction(target: Element | null, event: CustomEvent): void {
// Fully typed method
}
}
```

## License

Released under the [MIT License](LICENSE) by [Simon André](https://github.com/smnandre).