Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure we always render errors with the Append strategy #134

Merged
merged 41 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
409a0e5
Ensure we always render errors with the Append strategy
hopsoft Mar 2, 2024
6a6be4a
Start effort for Abort handling
hopsoft Mar 2, 2024
cec27c4
Switch to Rack middleware strategy for appending command data
hopsoft Mar 3, 2024
51f392d
Iterating...
hopsoft Mar 3, 2024
0e3f270
Rework state management
hopsoft Mar 4, 2024
b73d896
Update middleware comments
hopsoft Mar 4, 2024
36a7d08
Formatting
hopsoft Mar 4, 2024
5d9d683
Tweak log colors
hopsoft Mar 4, 2024
46a9e2f
Start looking into better error surfacing
hopsoft Mar 4, 2024
1d3527c
Get command error handling working
hopsoft Mar 5, 2024
8856f7c
Add comments for js libs
hopsoft Mar 5, 2024
1081b1d
Dispatch finish on abort
hopsoft Mar 5, 2024
d8952eb
Ensure finish event is dispatched on abort/error
hopsoft Mar 6, 2024
0dc9d94
Add note on state resolution
hopsoft Mar 6, 2024
56e7a38
Let storage manage JSON conversion
hopsoft Mar 6, 2024
9dc92fc
Tweaks to state exports
hopsoft Mar 6, 2024
930e00a
Minor state tweaks
hopsoft Mar 6, 2024
ba32c86
Setup abort test
hopsoft Mar 7, 2024
c2a6f74
Add tests to check for logs and alerts on abort and error
hopsoft Mar 7, 2024
5e0280e
Rework state management and add element attribute caching
hopsoft Mar 9, 2024
071a563
State updates with page state tracking
hopsoft Mar 21, 2024
0dc7df1
Get state initialization working on page load
hopsoft Mar 22, 2024
37a2193
Get optimistic state restoration working reliably
hopsoft Mar 25, 2024
2025c3b
Getting close on optimistic page state tracking and restoration
hopsoft Mar 25, 2024
4f7711f
Add missing testid
hopsoft Mar 25, 2024
48a3297
Get tests passing
hopsoft Mar 26, 2024
2b8bc31
Merge branch 'main' into hopsoft/render-errors
hopsoft Mar 26, 2024
da65e21
Attempt to improve test stability
hopsoft Mar 26, 2024
ce77bdb
Final touches before merge
hopsoft May 15, 2024
7f707e3
Update to note
hopsoft May 15, 2024
ca9d397
Update page state example
hopsoft May 15, 2024
f2fdcd5
Verbiage improvements
hopsoft May 15, 2024
8e4766c
Properly scope page state tracking based on path
hopsoft May 17, 2024
eff4d85
Cleanup
hopsoft May 17, 2024
5c24a5a
Update page state handler
hopsoft May 17, 2024
1ad18e7
Add a current state value that includes signed + now
hopsoft May 17, 2024
82dba15
Cleanup
hopsoft May 17, 2024
a2b7f4c
Introduce StateStore and bring back MemoryStore for state
hopsoft May 20, 2024
f370f22
Tests for state management
hopsoft May 20, 2024
3109cc5
Move to localStorage so state persists across tabs
hopsoft May 20, 2024
de66581
Add current and all states
hopsoft May 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
.byebug_history
.containers.yml
.pnp.*
.playwright*
.yarn*
/.bundle/
/doc/
Expand All @@ -26,4 +27,4 @@ test/dummy/log/*.log*
/.appmap

# Vendored Ruby gems
/vendor
/vendor
2 changes: 1 addition & 1 deletion .standard.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ruby_version: 2.7
ruby_version: 3.0
format: progress
parallel: true

Expand Down
89 changes: 62 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</h1>
<p align="center">
<a href="http://blog.codinghorror.com/the-best-code-is-no-code-at-all/">
<img alt="Lines of Code" src="https://img.shields.io/badge/loc-1450-47d299.svg" />
<img alt="Lines of Code" src="https://img.shields.io/badge/loc-1689-47d299.svg" />
</a>
<a href="https://codeclimate.com/github/hopsoft/turbo_boost-commands/maintainability">
<img src="https://api.codeclimate.com/v1/badges/fe1162a742fe83a4fdfd/maintainability" />
Expand Down Expand Up @@ -59,29 +59,30 @@

## Table of Contents

- [Why TurboBoost Commands?](#why-turboboost-commands)
- [Sponsors](#sponsors)
- [Dependencies](#dependencies)
- [Setup](#setup)
- [Usage](#usage)
- [Event Delegates](#event-delegates)
- [Lifecycle Events](#lifecycle-events)
- [Targeting Frames](#targeting-frames)
- [Working with Forms](#working-with-forms)
- [Server Side Commands](#server-side-commands)
- [Appending Turbo Streams](#appending-turbo-streams)
- [Setting Instance Variables](#setting-instance-variables)
- [Prevent Controller Action](#prevent-controller-action)
- [Broadcasting Turbo Streams](#broadcasting-turbo-streams)
- [Community](#community)
- [Developing](#developing)
- [Notable Files](#notable-files)
- [Deploying](#deploying)
- [Notable Files](#notable-files-1)
- [How to Deploy](#how-to-deploy)
- [Releasing](#releasing)
- [About TurboBoost](#about-turboboost)
- [License](#license)
- [Why TurboBoost Commands?](#why-turboboost-commands)
- [Sponsors](#sponsors)
- [Dependencies](#dependencies)
- [Setup](#setup)
- [Usage](#usage)
- [Event Delegates](#event-delegates)
- [Lifecycle Events](#lifecycle-events)
- [Targeting Frames](#targeting-frames)
- [Working with Forms](#working-with-forms)
- [Server Side Commands](#server-side-commands)
- [Appending Turbo Streams](#appending-turbo-streams)
- [Setting Instance Variables](#setting-instance-variables)
- [Prevent Controller Action](#prevent-controller-action)
- [Broadcasting Turbo Streams](#broadcasting-turbo-streams)
- [Tracking Page State](#tracking-page-state)
- [Community](#community)
- [Developing](#developing)
- [Notable Files](#notable-files)
- [Deploying](#deploying)
- [Notable Files](#notable-files-1)
- [How to Deploy](#how-to-deploy)
- [Releasing](#releasing)
- [About TurboBoost](#about-turboboost)
- [License](#license)

<!-- Tocer[finish]: Auto-generated, don't remove. -->

Expand Down Expand Up @@ -381,7 +382,8 @@ end

_This proves especially powerful when paired with [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams)._

> 📘 **NOTE:** `turbo_stream.invoke` is a [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams#usage) feature.
> [!NOTE]
> `turbo_stream.invoke` is a [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams#usage) feature.

### Setting Instance Variables

Expand Down Expand Up @@ -476,7 +478,40 @@ end
_Learn more about Turbo Stream broadcasting by reading through the
[hotwired/turbo-rails](https://github.com/hotwired/turbo-rails/blob/main/app/models/concerns/turbo/broadcastable.rb) source code._

> 📘 **NOTE:** `broadcast_invoke_later_to` is a [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams#broadcasting) feature.
> [!NOTE]
> `broadcast_invoke_later_to` is a [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams#broadcasting) feature.

### Tracking Page State

You can opt-in to remember transient page state when using Rails tag helpers with `turbo_boost[:remember]` to track
element attribute values between requests.

```erb
<%= tag.details id: "page-state-example", open: "open", turbo_boost: { remember: [:open] } do %>
<summary>Page State Example</summary>
Content...
<% end %>
```

The code above will be expanded to this HTML.

```html
<details id="page-state-example" open="open" data-turbo-boost-state-attributes="['open']">
<summary>Page State Example</summary>
Content...
</details>
```

Several things happen when you use `turbo_boost[:remember]` to track page state.

1. The client builds the current page state before emitting requests to the server.
1. The server uses the page state when rendering the response.
1. The client client verifies the page state and restores attribute values _(if necessary)_ after the DOM updates.

This feature works with all attributes, including aria, data, and custom attributes.

> [!NOTE]
> Elements must have a unique `id` assigned to participate in page state tracking.

## Community

Expand Down Expand Up @@ -558,7 +593,7 @@ fly deploy
1. Commit and push any changes to GitHub
1. Run `rake release`
1. Run `npm publish --access public`
1. Create a new release on GitHub ([here](https://github.com/hopsoft/turbo_boost-commands/releases)) and generate the changelog for the stable release for it
1. Create a new release on GitHub ([here](https://github.com/hopsoft/turbo_boost-commands/releases))

## About TurboBoost

Expand Down
2 changes: 1 addition & 1 deletion app/assets/builds/@turbo-boost/commands.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions app/assets/builds/@turbo-boost/commands.js.map

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module TurboBoost::Commands::Controller

included do
before_action -> { turbo_boost.runner.run }
after_action -> { turbo_boost.runner.update_response }
after_action -> { turbo_boost.runner.flush }
helper_method :turbo_boost
end

Expand Down
1 change: 0 additions & 1 deletion app/javascript/elements.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import schema from './schema'
import lifecycle from './lifecycle'

function findClosestCommand(element) {
return element.closest(`[${schema.commandAttribute}]`)
Expand Down
9 changes: 6 additions & 3 deletions app/javascript/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ export const commandEvents = {
}

export const stateEvents = {
stateLoad: 'turbo-boost:state:load',
stateChange: 'turbo-boost:state:change'
stateChange: 'turbo-boost:state:change',
stateInitialize: 'turbo-boost:state:initialize'
}

export const allEvents = { ...commandEvents, ...stateEvents }
export const turboEvents = {
frameLoad: 'turbo:frame-load',
load: 'turbo:load'
}

export function dispatch(name, target, options = {}) {
return new Promise(resolve => {
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ const prepare = (headers = {}) => {
// Tokenizes the 'TurboBoost-Command' HTTP response header value
const tokenize = value => {
if (value) {
const [status, strategy, name] = value.split(', ')
return { status, strategy, name }
const [name, status, strategy] = value.split(', ')
return { name, status, strategy }
}

return {}
Expand Down
30 changes: 19 additions & 11 deletions app/javascript/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import confirmation from './confirmation'
import delegates from './delegates'
import drivers from './drivers'
import elements from './elements'
import lifecycle from './lifecycle'
import './lifecycle'
import logger from './logger'
import state from './state'
import uuids from './uuids'
Expand All @@ -29,14 +29,16 @@ const Commands = {

function buildCommandPayload(id, element) {
return {
id, // uniquely identifies the command
name: element.getAttribute(schema.commandAttribute),
elementId: element.id.length > 0 ? element.id : null,
elementAttributes: elements.buildAttributePayload(element),
startedAt: Date.now(),
changedState: state.changed, // changed-state (delta of optimistic updates)
clientState: state.current, // client-side state
signedState: state.signed // server-side state
id, //---------------------------------------------------------- Uniquely identifies the command invocation
name: element.getAttribute(schema.commandAttribute), //--------- Command name
elementId: element.id.length > 0 ? element.id : null, //-------- ID of the element that triggered the command
elementAttributes: elements.buildAttributePayload(element), //-- Attributes of the element that triggered the command
startedAt: Date.now(), //--------------------------------------- Start time of when the command was invoked
state: {
page: state.buildPageState(),
signed: state.signed,
unsigned: state.unsigned
}
}
}

Expand All @@ -49,7 +51,7 @@ async function invokeCommand(event) {
if (!element) return
if (!delegates.isRegisteredForElement(event.type, element)) return

const commandId = `turbo-command-${uuids.v4()}`
const commandId = uuids.v4()
let driver = drivers.find(element)
let payload = {
...buildCommandPayload(commandId, element),
Expand Down Expand Up @@ -108,14 +110,20 @@ if (!self.TurboBoost.Commands) {
delegates.handler = invokeCommand
delegates.register('click', [`[${schema.commandAttribute}]`])
delegates.register('submit', [`form[${schema.commandAttribute}]`])
delegates.register('toggle', [`details[${schema.commandAttribute}]`])
delegates.register('change', [
`input[${schema.commandAttribute}]`,
`select[${schema.commandAttribute}]`,
`textarea[${schema.commandAttribute}]`
])

self.TurboBoost.Commands = Commands
self.TurboBoost.State = state
self.TurboBoost.State = {
initialize: state.initialize,
get current() {
return state.unsigned
}
}
}

export default Commands
12 changes: 2 additions & 10 deletions app/javascript/invoker.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
import headers from './headers'
import lifecycle from './lifecycle'
import state from './state'
import urls from './urls'
import { dispatch } from './events'
import { render } from './renderer'

const parseError = error => {
const errorMessage = `Unexpected error performing a TurboBoost Command! ${error.message}`
dispatch(lifecycle.events.clientError, document, { detail: { error: errorMessage } }, true)
const message = `Unexpected error performing a TurboBoost Command! ${error.message}`
dispatch(lifecycle.events.clientError, document, { detail: { message, error } }, true)
}

const parseAndRenderResponse = response => {
const { strategy } = headers.tokenize(response.headers.get(headers.RESPONSE_HEADER))

// FAIL: Status outside the range of 200-399
if (response.status < 200 || response.status > 399) {
const error = `Server returned a ${response.status} status code! TurboBoost Commands require 2XX-3XX status codes.`
dispatch(lifecycle.events.serverError, document, { detail: { error, response } }, true)
}

response.text().then(content => render(strategy, content))
}

Expand Down
9 changes: 3 additions & 6 deletions app/javascript/lifecycle.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@ import activity from './activity'
import { dispatch, commandEvents } from './events'

function finish(event) {
event.detail.endedAt = Date.now()
event.detail.milliseconds = event.detail.endedAt - event.detail.startedAt
setTimeout(() => dispatch(commandEvents.finish, event.target, { detail: event.detail }), 25)
setTimeout(() => dispatch(commandEvents.finish, event.target, { detail: event.detail }))
}

// TODO: forward source event to finish (error or success)
addEventListener(commandEvents.serverError, finish)
addEventListener(commandEvents.success, finish)
const events = [commandEvents.abort, commandEvents.serverError, commandEvents.success]
events.forEach(name => addEventListener(name, finish))
addEventListener(commandEvents.finish, event => activity.remove(event.detail.id), true)

export default { events: commandEvents }
31 changes: 29 additions & 2 deletions app/javascript/logger.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { allEvents as events } from './events'
// TODO: Move Logger to its own library (i.e. TurboBoost.Logger)
import { commandEvents as events } from './events'

let currentLevel = 'unknown'
let initialized = false
Expand Down Expand Up @@ -28,10 +29,36 @@ const shouldLogEvent = event => {
return true
}

const logMethod = event => {
if (logLevels.error.includes(event.type)) return 'error'
if (logLevels.warn.includes(event.type)) return 'warn'
if (logLevels.info.includes(event.type)) return 'info'
if (logLevels.debug.includes(event.type)) return 'debug'
return 'log'
}

const logEvent = event => {
if (shouldLogEvent(event)) {
const { target, type, detail } = event
console[currentLevel](type, detail.id || '', { target, detail })
const id = detail.id || ''
const commandName = detail.name || ''

let duration = ''
if (detail.startedAt) duration = `${Date.now() - detail.startedAt}ms `

const typeParts = type.split(':')
const lastPart = typeParts.pop()
const eventName = `%c${typeParts.join(':')}:%c${lastPart}`
const message = [`%c${commandName}`, `%c${duration}`, eventName]

console[logMethod(event)](
message.join(' ').replace(/\s{2,}/g, ' '),
'color:deepskyblue',
'color:lime',
'color:darkgray',
eventName.match(/abort|error/i) ? 'color:red' : 'color:deepskyblue',
{ id, detail, target }
)
}
}

Expand Down
16 changes: 11 additions & 5 deletions app/javascript/renderer.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import uuids from './uuids'

const append = content => {
document.body.insertAdjacentHTML('beforeend', content)
}

const replace = content => {
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/html')
TurboBoost.Streams.morph.method(document.documentElement, doc.documentElement)
const head = document.querySelector('head')
const body = document.querySelector('body')
const newHead = doc.querySelector('head')
const newBody = doc.querySelector('body')
if (head && newHead) TurboBoost?.Streams?.morph?.method(head, newHead)
if (body && newBody) TurboBoost?.Streams?.morph?.method(body, newBody)
}

// TODO: dispatch events after append/replace so we can apply page state
export const render = (strategy, content) => {
if (strategy.match(/^Append$/i)) return append(content)
if (strategy.match(/^Replace$/i)) return replace(content)
if (strategy && content) {
if (strategy.match(/^Append$/i)) return append(content)
if (strategy.match(/^Replace$/i)) return replace(content)
}
}

export default { render }
3 changes: 2 additions & 1 deletion app/javascript/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ const schema = {
frameAttribute: 'data-turbo-frame',
methodAttribute: 'data-turbo-method',
commandAttribute: 'data-turbo-command',
confirmAttribute: 'data-turbo-confirm'
confirmAttribute: 'data-turbo-confirm',
stateAttributesAttribute: 'data-turbo-boost-state-attributes'
}

export default { ...schema }
Loading
Loading