activeadmin-react 0.1.0.alpha1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE.txt +9 -0
- data/README.md +203 -0
- data/RELEASES.md +53 -0
- data/app/javascript/active_admin/react/cable.js +84 -0
- data/app/javascript/active_admin/react/index.js +13 -0
- data/app/javascript/active_admin/react/operation.js +73 -0
- data/app/javascript/active_admin/react/protocol.js +102 -0
- data/app/javascript/active_admin/react/registry.js +18 -0
- data/app/javascript/active_admin/react/runtime.js +73 -0
- data/docs/README.md +16 -0
- data/docs/releasing.md +59 -0
- data/lib/active_admin/react/arbre.rb +21 -0
- data/lib/active_admin/react/contributions.rb +27 -0
- data/lib/active_admin/react/mount.rb +82 -0
- data/lib/active_admin/react/registry.rb +134 -0
- data/lib/active_admin/react/version.rb +8 -0
- data/lib/active_admin/react.rb +16 -0
- data/sig/active_admin/react.rbs +32 -0
- metadata +106 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 92cba0113794ff8883e09e5bf1cfad4b498c705bf97fdabcf2c461c2d320e04c
|
|
4
|
+
data.tar.gz: 54dbdaed377bcd530612b4077ad9364b167b7a268438b09d8d038ff15d798052
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3aead5075fda511f7d0ce83ae644860eb2831432c93c99aeab3a68b5b8c07d513049237d1826db73b2bd79971d45845db449e4a113f09d874e8d709893ce95ee
|
|
7
|
+
data.tar.gz: dff36285667c02f65f3a52fe0f24ef0f52a5428e8be2640dd142f837b4cb42b4eeb799a3f7a18e119ea4a49c0c3c9ccda73bd16054b1ad6f5d18f157a1b2c87c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!-- CHANGELOG.md -->
|
|
2
|
+
|
|
3
|
+
# Changelog
|
|
4
|
+
|
|
5
|
+
All notable changes to ActiveAdmin React are recorded here. During ordinary pre-1.0 development, PATCH releases contain fixes and small compatible improvements; MINOR releases may contain new capabilities, meaningful API evolution, and documented breaking changes.
|
|
6
|
+
|
|
7
|
+
## 0.1.0.alpha1 — 2026-09-05
|
|
8
|
+
|
|
9
|
+
First integrated prerelease for Rodeo dogfooding. Public Ruby and JavaScript contracts
|
|
10
|
+
remain unstable during the 0.x line. This alpha includes the capabilities below and
|
|
11
|
+
real Chromium coverage for engine rendering, Turbo cleanup/remount, server fallbacks,
|
|
12
|
+
and live Action Cable reconnect/replay. Supported runtime dependencies are Ruby >= 3.2,
|
|
13
|
+
Rails >= 8.0 and < 9, and ActiveAdmin >= 4.0.0.beta22 and < 5; React 18/19 is supplied
|
|
14
|
+
by the host.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Arbre-native `react_component` islands with deterministic markup, JSON-safe props, caller-owned HTML attributes, and server-rendered fallback content.
|
|
19
|
+
- A build-tool-neutral React 18/19 runtime with explicit component registration, multiple islands per page, Turbo lifecycle cleanup, and duplicate-registration protection.
|
|
20
|
+
- A validated Action Cable operation protocol with reconnect replay, duplicate and out-of-order suppression, terminal states, authenticated cancellation commands, and accessible status attributes.
|
|
21
|
+
- Explicit Rails engine contribution contracts with ownership, namespaces, surfaces, deterministic diagnostics, collision errors, and RBS signatures.
|
|
22
|
+
- An ActiveAdmin 4 dummy host, focused local validation commands, independent CI quality gates, packaged JavaScript and RBS files, and clean-install package verification.
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
|
|
26
|
+
- Props reject unsupported objects and non-finite floats instead of serializing arbitrary values.
|
|
27
|
+
- Mount points reserve their runtime data attributes and render no inline JavaScript.
|
|
28
|
+
- Action Cable examples keep user and tenant authorization on the server, and cancellation uses same-origin requests with Rails CSRF tokens.
|
|
29
|
+
|
|
30
|
+
—
|
|
31
|
+
Stan Carver II
|
|
32
|
+
Made in Texas 🤠
|
|
33
|
+
https://stancarver.com
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stan Carver II
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<!-- README.md -->
|
|
2
|
+
|
|
3
|
+
# ActiveAdmin React
|
|
4
|
+
|
|
5
|
+
React islands for ActiveAdmin, with an Arbre-native Ruby API and optional asynchronous integrations.
|
|
6
|
+
|
|
7
|
+
ActiveAdmin React keeps administrative pages Rails-first and server-rendered while making React available for interactions that benefit from a client-side component. The project is on the ordinary pre-1.0 `0.x` line, so documented breaking changes may ship in a MINOR release while Rodeo dogfooding helps stabilize the public contracts.
|
|
8
|
+
|
|
9
|
+
## Installation and compatibility
|
|
10
|
+
|
|
11
|
+
Add the gem to a Rails application that uses ActiveAdmin:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "activeadmin-react", "0.1.0.alpha1", require: "active_admin/react"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then run `bundle install`. The gem requires Ruby 3.2 or newer, Rails 8.x, and ActiveAdmin `4.0.0.beta22` or newer within the 4.x line. The JavaScript runtime uses the React 18/19 `createRoot` API; the host supplies `react` and `react-dom` and remains responsible for compiling and serving browser assets.
|
|
18
|
+
|
|
19
|
+
## Render an island from Arbre
|
|
20
|
+
|
|
21
|
+
The `react_component` helper is available inside ActiveAdmin's Arbre DSL:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
ActiveAdmin.register Order do
|
|
25
|
+
show do
|
|
26
|
+
panel "Order activity" do
|
|
27
|
+
react_component(
|
|
28
|
+
"OrdersTable",
|
|
29
|
+
props: { order_id: resource.id },
|
|
30
|
+
fallback: -> { "Order activity is available without JavaScript." },
|
|
31
|
+
class: "orders-table"
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Props may contain `nil`, booleans, strings, integers, finite floats, symbols, dates, times, arrays, and hashes with string or symbol keys. Symbols become strings and date/time values become ISO 8601 strings. Unsupported values, non-finite floats, invalid component names, and malformed `data` attributes raise `ArgumentError` before markup is rendered.
|
|
39
|
+
|
|
40
|
+
The mount owns the `data-react-component` and `data-react-props` attributes. Other HTML and data attributes remain caller-owned. A callable fallback receives polite `status` semantics by default and stays in the page until React mounts successfully.
|
|
41
|
+
|
|
42
|
+
## Register and start components
|
|
43
|
+
|
|
44
|
+
The packaged JavaScript entrypoint is `app/javascript/active_admin/react/index.js`. Configure the host's Vite, esbuild, or equivalent resolver so `active_admin/react` points to that file inside the installed gem. For example, Vite can derive the gem root with `bundle show activeadmin-react`:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
import { execFileSync } from "node:child_process"
|
|
48
|
+
import { resolve } from "node:path"
|
|
49
|
+
import { defineConfig } from "vite"
|
|
50
|
+
|
|
51
|
+
const gemRoot = execFileSync("bundle", ["show", "activeadmin-react"], {
|
|
52
|
+
encoding: "utf8"
|
|
53
|
+
}).trim()
|
|
54
|
+
|
|
55
|
+
export default defineConfig({
|
|
56
|
+
resolve: {
|
|
57
|
+
alias: {
|
|
58
|
+
"active_admin/react": resolve(gemRoot, "app/javascript/active_admin/react/index.js")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Register every component before starting the runtime:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { registerComponent, start } from "active_admin/react"
|
|
68
|
+
import OrdersTable from "./components/OrdersTable"
|
|
69
|
+
|
|
70
|
+
registerComponent("OrdersTable", OrdersTable)
|
|
71
|
+
start()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`start()` mounts every `[data-react-component]` island, mounts newly rendered pages after `turbo:load`, and unmounts roots before Turbo caches the page. Repeated calls are safe. `stop()` removes the Turbo listeners and unmounts tracked roots. Duplicate component names, unknown components, and malformed JSON props fail loudly.
|
|
75
|
+
|
|
76
|
+
The shipped modules use package-style relative imports intended for a JavaScript build tool. Copying the directory directly into an importmap or serving it to browsers without a resolver is not currently a supported integration path.
|
|
77
|
+
|
|
78
|
+
## Engine contributions
|
|
79
|
+
|
|
80
|
+
Requiring `active_admin/react` does not scan Rails engines, eager-load models, or require third-party adapters. An engine exposes a small adapter and the host installs it explicitly:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
module CommerceEngine
|
|
84
|
+
module ActiveAdminReact
|
|
85
|
+
module_function
|
|
86
|
+
|
|
87
|
+
def install!
|
|
88
|
+
ActiveAdmin::React::Contributions.register(
|
|
89
|
+
"OrdersTable",
|
|
90
|
+
namespace: "commerce.admin",
|
|
91
|
+
owner: "CommerceEngine",
|
|
92
|
+
source: "commerce_engine/active_admin_react",
|
|
93
|
+
surfaces: %i[component page],
|
|
94
|
+
description: "Interactive order review"
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`name`, `namespace`, `owner`, and `source` are required. `surfaces` defaults to `[:component]`; additional keywords become diagnostic metadata. Component names are globally unique. Conflicts identify both owners, namespaces, and sources.
|
|
102
|
+
|
|
103
|
+
`ActiveAdmin::React::Contributions.diagnostics` returns entries sorted by namespace, component name, and owner. An installer can query `ActiveAdmin::React::Contributions.registry.registered?("OrdersTable")` before registering inside a reload hook. `ActiveAdmin::React::Contributions.reset!` creates a fresh registry for test isolation. Metadata hashes, arrays, sets, and strings are recursively copied and frozen during registration, so later changes to caller-owned values cannot alter registered state and diagnostics cannot mutate it. Other metadata values must be immutable objects supplied by the contributor.
|
|
104
|
+
|
|
105
|
+
## Asynchronous Action Cable operations
|
|
106
|
+
|
|
107
|
+
Action Cable transports operation state; application jobs and services own the expensive work. Each event uses a server-owned operation identifier, idempotency key, and monotonic sequence:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"operation_id": "report-123",
|
|
112
|
+
"idempotency_key": "report-123:7",
|
|
113
|
+
"sequence": 7,
|
|
114
|
+
"state": "running",
|
|
115
|
+
"progress": 60,
|
|
116
|
+
"message": "Rendering pages",
|
|
117
|
+
"result": null,
|
|
118
|
+
"result_metadata": null,
|
|
119
|
+
"error": null,
|
|
120
|
+
"occurred_at": "2026-09-03T18:42:00Z"
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
States are `pending`, `queued`, `running`, `retrying`, `completed`, `failed`, and `cancelled`; the last three are terminal. `OperationState` ignores duplicates, out-of-order events, events for another operation, and updates after a terminal event. `subscribeToOperation` validates envelopes and resumes after the last applied sequence:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
import { OperationState, subscribeToOperation } from "active_admin/react"
|
|
128
|
+
|
|
129
|
+
const operationState = new OperationState({ operationId })
|
|
130
|
+
const subscription = subscribeToOperation({
|
|
131
|
+
consumer,
|
|
132
|
+
channel: "OperationsChannel",
|
|
133
|
+
params: { operation_id: operationId },
|
|
134
|
+
operationState,
|
|
135
|
+
onEvent: (_event, current) => setOperation(current),
|
|
136
|
+
onProtocolError: (error) => reportProtocolError(error)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
return () => subscription.unsubscribe()
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Use `operationAccessibility(operationState)` on the visible status container. Active work returns a polite, busy `status`; successful and cancelled work returns a polite, non-busy status; failures return an assertive `alert`.
|
|
143
|
+
|
|
144
|
+
Cancellation is an authenticated application command rather than a Cable event:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { requestOperationCancellation } from "active_admin/react"
|
|
148
|
+
|
|
149
|
+
await requestOperationCancellation({
|
|
150
|
+
url: `/admin/operations/${operationId}/cancel`,
|
|
151
|
+
operationId,
|
|
152
|
+
csrfToken: document.querySelector('meta[name="csrf-token"]')?.content
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The helper sends only `operation_id` with same-origin credentials. The endpoint must load and authorize the operation in the current user and tenant context before asking the owning job or service to cancel it. Channels must apply the same server-side authorization before streaming or replaying events.
|
|
157
|
+
|
|
158
|
+
## CSP, CSRF, and fallbacks
|
|
159
|
+
|
|
160
|
+
The gem emits no inline scripts and does not create CSP nonces. Compile and serve React assets through the host's normal CSP-aware pipeline. Keep CSRF tokens in Rails-managed forms or meta tags rather than component props. Avoid placing credentials or sensitive tenant context in props because mount data is visible in HTML.
|
|
161
|
+
|
|
162
|
+
Meaningful fallback content keeps the page usable when JavaScript is disabled or an asset fails. Keep progress text visible, associate progress bars with their labels, and never communicate state through color alone.
|
|
163
|
+
|
|
164
|
+
## Development and testing
|
|
165
|
+
|
|
166
|
+
Install pinned runtimes and dependencies, then run the complete local quality gate:
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
bin/setup
|
|
170
|
+
bin/test
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Focused checks remain directly runnable:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
mise exec -- bundle exec rake spec
|
|
177
|
+
mise exec -- bundle exec rake rubocop
|
|
178
|
+
mise exec -- bundle exec rake rbs
|
|
179
|
+
mise exec -- npm run test:js
|
|
180
|
+
mise exec -- bundle exec rspec spec/integration
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`bin/test` includes the dummy ActiveAdmin 4 integration host through the full RSpec suite. `bin/package` builds the gem, verifies its contents, installs it and its dependencies into an isolated gem home, and proves Rails loads the installed copy. These validation commands never publish.
|
|
184
|
+
|
|
185
|
+
Ruby APIs live under `lib/`, browser modules under `app/javascript/`, RBS signatures under `sig/`, the dummy host under `spec/dummy/`, JavaScript tests under `test/javascript/`, and maintainer documentation under `docs/`.
|
|
186
|
+
|
|
187
|
+
## Troubleshooting
|
|
188
|
+
|
|
189
|
+
- `Unknown React component` means the exact, case-sensitive mount name was not registered before `start()` ran.
|
|
190
|
+
- Prop errors mean the Arbre call received a value outside the documented grammar. Keep credentials, tenant identifiers, and CSRF tokens out of props.
|
|
191
|
+
- Duplicate registration errors identify competing engine owners and sources. Rename the component or remove the duplicate adapter.
|
|
192
|
+
- Repeated mounts usually mean both the host and `start()` own Turbo lifecycle listeners. Keep one lifecycle owner and call `stop()` before replacing it.
|
|
193
|
+
- Rejected Cable subscriptions belong at the server authorization boundary. Never trust client-provided user or tenant parameters.
|
|
194
|
+
- Browser resolution errors usually mean the host alias does not point to the packaged `app/javascript/active_admin/react/index.js` or the build tool is not resolving relative imports.
|
|
195
|
+
|
|
196
|
+
## Documentation and license
|
|
197
|
+
|
|
198
|
+
See the [documentation index](docs/README.md), [release process](docs/releasing.md), [release policy](RELEASES.md), and [changelog](CHANGELOG.md). ActiveAdmin React is available under the [MIT License](LICENSE.txt). Source, issues, and pull requests live in the [GitHub repository](https://github.com/scarver2/activeadmin-react).
|
|
199
|
+
|
|
200
|
+
—
|
|
201
|
+
Stan Carver II
|
|
202
|
+
Made in Texas 🤠
|
|
203
|
+
https://stancarver.com
|
data/RELEASES.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!-- RELEASES.md -->
|
|
2
|
+
|
|
3
|
+
# Release Policy
|
|
4
|
+
|
|
5
|
+
ActiveAdmin React uses capability-gated Semantic Versioning. There are no calendar
|
|
6
|
+
commitments: quality gates, Rodeo dogfooding, community feedback, and ActiveAdmin 4
|
|
7
|
+
maturity determine when a release is ready.
|
|
8
|
+
|
|
9
|
+
## Pre-1.0 versions
|
|
10
|
+
|
|
11
|
+
Development uses ordinary `0.MINOR.PATCH` versions, with explicit `0.MINOR.PATCH.alphaN`
|
|
12
|
+
and `0.MINOR.PATCH.betaN` prereleases when authorized. The first integrated prerelease
|
|
13
|
+
is `0.1.0.alpha1`. Prerelease numbers start at 1 and have no leading zeroes.
|
|
14
|
+
|
|
15
|
+
- Increment PATCH for fixes and small backward-compatible improvements.
|
|
16
|
+
- Increment MINOR for new capabilities, meaningful API evolution, and documented breaking
|
|
17
|
+
changes while the public API remains unstable under Semantic Versioning's `0.y.z` rules.
|
|
18
|
+
- Keep Rodeo-specific business behavior outside the gem. Rodeo dogfooding supplies the
|
|
19
|
+
primary evidence for whether generally useful contracts are ready to stabilize.
|
|
20
|
+
- Track ActiveAdmin 4 closely and consider generally useful ActiveAdmin or Arbre fixes for
|
|
21
|
+
upstream contribution instead of permanent private patches.
|
|
22
|
+
|
|
23
|
+
## Path to 1.0
|
|
24
|
+
|
|
25
|
+
Move to `1.0.0.rc1` only when Rodeo dogfooding indicates that the Ruby API, JavaScript
|
|
26
|
+
adapter protocol, security guidance, packaging, and compatibility policy are ready to
|
|
27
|
+
stabilize. Publish additional candidates as `1.0.0.rcN` when needed, then publish `1.0.0`
|
|
28
|
+
after only release-blocking defects remain.
|
|
29
|
+
|
|
30
|
+
The stable release guarantees an Arbre-native mounting API, deterministic React lifecycle,
|
|
31
|
+
documented React and ActiveAdmin compatibility, Action Cable-friendly asynchronous
|
|
32
|
+
integration, engine contribution contracts, CSP/CSRF guidance, and semantic versioning for
|
|
33
|
+
public APIs.
|
|
34
|
+
|
|
35
|
+
## Release mechanics
|
|
36
|
+
|
|
37
|
+
1. Merge the entire reviewed stack into `master`.
|
|
38
|
+
2. Ensure CI is green at the exact release commit.
|
|
39
|
+
3. Update `ActiveAdmin::React::VERSION` and release notes.
|
|
40
|
+
4. Tag that exact commit with the matching `v0.MINOR.PATCH`, optional `.alphaN` or
|
|
41
|
+
`.betaN` suffix, or future `v1.0.0.rcN` tag. Numeric components have no leading zeroes.
|
|
42
|
+
5. Let GitHub Actions publish through RubyGems Trusted Publishing and the `release`
|
|
43
|
+
environment.
|
|
44
|
+
6. Verify the gem is installable and its provenance is visible on RubyGems.org.
|
|
45
|
+
7. Create or complete the corresponding GitHub Release.
|
|
46
|
+
|
|
47
|
+
Never publish from an unreviewed working tree or store a long-lived RubyGems API key when
|
|
48
|
+
Trusted Publishing is available.
|
|
49
|
+
|
|
50
|
+
—
|
|
51
|
+
Stan Carver II
|
|
52
|
+
Made in Texas 🤠
|
|
53
|
+
https://stancarver.com
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// app/javascript/active_admin/react/cable.js
|
|
2
|
+
|
|
3
|
+
import { normalizeEvent, validateOperationEvent } from "./protocol"
|
|
4
|
+
|
|
5
|
+
export function subscribeToOperation({
|
|
6
|
+
consumer,
|
|
7
|
+
channel,
|
|
8
|
+
params = {},
|
|
9
|
+
operationState = null,
|
|
10
|
+
strict = true,
|
|
11
|
+
resume = true,
|
|
12
|
+
onEvent,
|
|
13
|
+
onIgnoredEvent,
|
|
14
|
+
onProtocolError,
|
|
15
|
+
onConnected,
|
|
16
|
+
onDisconnected,
|
|
17
|
+
onRejected
|
|
18
|
+
}) {
|
|
19
|
+
if (!consumer?.subscriptions?.create) throw new Error("consumer is required")
|
|
20
|
+
if (typeof channel !== "string" || channel.trim().length === 0) throw new Error("channel is required")
|
|
21
|
+
|
|
22
|
+
const identifier = { channel, ...params }
|
|
23
|
+
let subscription
|
|
24
|
+
|
|
25
|
+
subscription = consumer.subscriptions.create(identifier, {
|
|
26
|
+
connected() {
|
|
27
|
+
const resumeFrom = operationState?.lastSequence ?? null
|
|
28
|
+
if (resume && resumeFrom !== null && typeof subscription?.perform === "function") {
|
|
29
|
+
subscription.perform("resume", { after_sequence: resumeFrom })
|
|
30
|
+
}
|
|
31
|
+
onConnected?.({ resumeFrom })
|
|
32
|
+
},
|
|
33
|
+
disconnected(details) {
|
|
34
|
+
onDisconnected?.(details)
|
|
35
|
+
},
|
|
36
|
+
rejected() {
|
|
37
|
+
onRejected?.()
|
|
38
|
+
},
|
|
39
|
+
received(event) {
|
|
40
|
+
try {
|
|
41
|
+
const normalized = strict ? validateOperationEvent(event) : normalizeEvent(event)
|
|
42
|
+
const outcome = operationState?.applyEvent(normalized)
|
|
43
|
+
if (outcome && !outcome.applied) {
|
|
44
|
+
onIgnoredEvent?.(normalized, outcome.reason)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
onEvent?.(normalized, outcome?.value ?? normalized)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (onProtocolError) onProtocolError(error, event)
|
|
50
|
+
else throw error
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return subscription
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function requestOperationCancellation({ url, operationId, csrfToken, fetchImpl = globalThis.fetch }) {
|
|
59
|
+
if (typeof url !== "string" || url.length === 0) throw new Error("url is required")
|
|
60
|
+
if (typeof operationId !== "string" || operationId.length === 0) throw new Error("operationId is required")
|
|
61
|
+
if (typeof fetchImpl !== "function") throw new Error("fetch is required")
|
|
62
|
+
|
|
63
|
+
const headers = { Accept: "application/json", "Content-Type": "application/json" }
|
|
64
|
+
if (csrfToken) headers["X-CSRF-Token"] = csrfToken
|
|
65
|
+
|
|
66
|
+
const response = await fetchImpl(url, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
credentials: "same-origin",
|
|
69
|
+
headers,
|
|
70
|
+
body: JSON.stringify({ operation_id: operationId })
|
|
71
|
+
})
|
|
72
|
+
const payload = await response.json()
|
|
73
|
+
if (!response.ok) throw new OperationCancellationError(response.status, payload)
|
|
74
|
+
return payload
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class OperationCancellationError extends Error {
|
|
78
|
+
constructor(status, response) {
|
|
79
|
+
super(`Operation cancellation failed with HTTP ${status}`)
|
|
80
|
+
this.name = "OperationCancellationError"
|
|
81
|
+
this.status = status
|
|
82
|
+
this.response = response
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// app/javascript/active_admin/react/index.js
|
|
2
|
+
|
|
3
|
+
export { clearComponents, registerComponent, resolveComponent } from "./registry"
|
|
4
|
+
export { mountAll, mountElement, start, stop, unmountAll, unmountElement } from "./runtime"
|
|
5
|
+
export { OperationCancellationError, requestOperationCancellation, subscribeToOperation } from "./cable"
|
|
6
|
+
export { OperationState, operationAccessibility } from "./operation"
|
|
7
|
+
export {
|
|
8
|
+
normalizeEvent,
|
|
9
|
+
OPERATION_STATES,
|
|
10
|
+
OperationProtocolError,
|
|
11
|
+
TERMINAL_OPERATION_STATES,
|
|
12
|
+
validateOperationEvent
|
|
13
|
+
} from "./protocol"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// app/javascript/active_admin/react/operation.js
|
|
2
|
+
|
|
3
|
+
import { normalizeEvent, TERMINAL_OPERATION_STATES } from "./protocol"
|
|
4
|
+
|
|
5
|
+
export class OperationState {
|
|
6
|
+
constructor(initial = {}) {
|
|
7
|
+
this.value = {
|
|
8
|
+
operationId: initial.operationId ?? initial.operation_id ?? null,
|
|
9
|
+
id: initial.id ?? null,
|
|
10
|
+
idempotencyKey: initial.idempotencyKey ?? initial.idempotency_key ?? initial.id ?? null,
|
|
11
|
+
sequence: initial.sequence ?? null,
|
|
12
|
+
state: initial.state ?? "pending",
|
|
13
|
+
progress: initial.progress ?? null,
|
|
14
|
+
message: initial.message ?? null,
|
|
15
|
+
result: initial.result ?? null,
|
|
16
|
+
resultMetadata: initial.resultMetadata ?? initial.result_metadata ?? null,
|
|
17
|
+
error: initial.error ?? null,
|
|
18
|
+
occurredAt: initial.occurredAt ?? null
|
|
19
|
+
}
|
|
20
|
+
this.seen = new Set()
|
|
21
|
+
if (this.value.idempotencyKey) this.seen.add(this.value.idempotencyKey)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
apply(event) {
|
|
25
|
+
return this.applyEvent(event).value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
applyEvent(event) {
|
|
29
|
+
const normalized = normalizeEvent(event)
|
|
30
|
+
const key = normalized.idempotencyKey
|
|
31
|
+
|
|
32
|
+
if (key && this.seen.has(key)) return this.ignored("duplicate")
|
|
33
|
+
if (this.operationMismatch(normalized)) return this.ignored("operation_mismatch")
|
|
34
|
+
if (this.outOfOrder(normalized)) return this.ignored("out_of_order")
|
|
35
|
+
if (this.terminal()) return this.ignored("terminal")
|
|
36
|
+
|
|
37
|
+
if (key) this.seen.add(key)
|
|
38
|
+
this.value = { ...this.value, ...normalized }
|
|
39
|
+
return { applied: true, reason: null, value: this.value }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get lastSequence() {
|
|
43
|
+
return this.value.sequence
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ignored(reason) {
|
|
47
|
+
return { applied: false, reason, value: this.value }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
operationMismatch(event) {
|
|
51
|
+
return Boolean(this.value.operationId && event.operationId && this.value.operationId !== event.operationId)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
outOfOrder(event) {
|
|
55
|
+
return event.sequence !== null && this.lastSequence !== null && event.sequence <= this.lastSequence
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
terminal() {
|
|
59
|
+
return TERMINAL_OPERATION_STATES.includes(this.value.state)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function operationAccessibility(operation) {
|
|
64
|
+
const value = operation instanceof OperationState ? operation.value : operation
|
|
65
|
+
const state = value?.state ?? "pending"
|
|
66
|
+
|
|
67
|
+
if (state === "failed") return { role: "alert", "aria-live": "assertive", "aria-busy": false }
|
|
68
|
+
return {
|
|
69
|
+
role: "status",
|
|
70
|
+
"aria-live": "polite",
|
|
71
|
+
"aria-busy": !TERMINAL_OPERATION_STATES.includes(state)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// app/javascript/active_admin/react/protocol.js
|
|
2
|
+
|
|
3
|
+
export const OPERATION_STATES = Object.freeze([
|
|
4
|
+
"pending",
|
|
5
|
+
"queued",
|
|
6
|
+
"running",
|
|
7
|
+
"retrying",
|
|
8
|
+
"completed",
|
|
9
|
+
"failed",
|
|
10
|
+
"cancelled"
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
export const TERMINAL_OPERATION_STATES = Object.freeze(["completed", "failed", "cancelled"])
|
|
14
|
+
|
|
15
|
+
export class OperationProtocolError extends Error {
|
|
16
|
+
constructor(issues) {
|
|
17
|
+
super(`Invalid operation event: ${issues.join(", ")}`)
|
|
18
|
+
this.name = "OperationProtocolError"
|
|
19
|
+
this.issues = issues
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeEvent(event = {}) {
|
|
24
|
+
const source = event && typeof event === "object" && !Array.isArray(event) ? event : {}
|
|
25
|
+
const idempotencyKey = source.idempotency_key ?? source.idempotencyKey ?? source.id ?? null
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
operationId: source.operation_id ?? source.operationId ?? null,
|
|
29
|
+
id: source.event_id ?? source.eventId ?? source.id ?? idempotencyKey,
|
|
30
|
+
idempotencyKey,
|
|
31
|
+
sequence: normalizeSequence(source.sequence),
|
|
32
|
+
state: normalizeState(source.state),
|
|
33
|
+
progress: clampProgress(source.progress),
|
|
34
|
+
message: source.message ?? null,
|
|
35
|
+
result: source.result ?? null,
|
|
36
|
+
resultMetadata: source.result_metadata ?? source.resultMetadata ?? null,
|
|
37
|
+
error: normalizeError(source.error),
|
|
38
|
+
occurredAt: source.occurred_at ?? source.occurredAt ?? null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateOperationEvent(event) {
|
|
43
|
+
const issues = []
|
|
44
|
+
|
|
45
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
46
|
+
throw new OperationProtocolError(["event must be an object"])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const normalized = normalizeEvent(event)
|
|
50
|
+
if (!nonEmptyString(normalized.operationId)) issues.push("operation_id is required")
|
|
51
|
+
if (!nonEmptyString(normalized.idempotencyKey)) issues.push("idempotency_key is required")
|
|
52
|
+
if (!validSequence(event.sequence)) issues.push("sequence must be a non-negative integer")
|
|
53
|
+
if (!OPERATION_STATES.includes(normalized.state)) issues.push(`state must be one of ${OPERATION_STATES.join("/")}`)
|
|
54
|
+
if (!validProgress(event.progress)) issues.push("progress must be between 0 and 100")
|
|
55
|
+
if (normalized.state === "failed" && normalized.error === null) issues.push("failed events require an error")
|
|
56
|
+
|
|
57
|
+
if (issues.length > 0) throw new OperationProtocolError(issues)
|
|
58
|
+
return normalized
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function clampProgress(value) {
|
|
62
|
+
if (value === null || value === undefined) return null
|
|
63
|
+
const numeric = Number(value)
|
|
64
|
+
if (!Number.isFinite(numeric)) return null
|
|
65
|
+
return Math.min(100, Math.max(0, numeric))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function nonEmptyString(value) {
|
|
69
|
+
return typeof value === "string" && value.trim().length > 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeError(error) {
|
|
73
|
+
if (error === null || error === undefined) return null
|
|
74
|
+
if (typeof error === "string") return { code: null, message: error, retryable: false, details: null }
|
|
75
|
+
if (typeof error !== "object" || Array.isArray(error)) return null
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
code: error.code ?? null,
|
|
79
|
+
message: error.message ?? null,
|
|
80
|
+
retryable: error.retryable === true,
|
|
81
|
+
details: error.details ?? null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeSequence(value) {
|
|
86
|
+
const numeric = Number(value)
|
|
87
|
+
return Number.isInteger(numeric) && numeric >= 0 ? numeric : null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeState(value) {
|
|
91
|
+
return typeof value === "string" ? value.toLowerCase() : "unknown"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validProgress(value) {
|
|
95
|
+
if (value === null || value === undefined) return true
|
|
96
|
+
const numeric = Number(value)
|
|
97
|
+
return Number.isFinite(numeric) && numeric >= 0 && numeric <= 100
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validSequence(value) {
|
|
101
|
+
return Number.isInteger(value) && value >= 0
|
|
102
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const components = new Map()
|
|
2
|
+
|
|
3
|
+
export function registerComponent(name, component) {
|
|
4
|
+
if (!name || !component) throw new Error("name and component are required")
|
|
5
|
+
|
|
6
|
+
const key = String(name)
|
|
7
|
+
if (components.has(key)) throw new Error(`component already registered: ${key}`)
|
|
8
|
+
|
|
9
|
+
components.set(key, component)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveComponent(name) {
|
|
13
|
+
return components.get(String(name))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clearComponents() {
|
|
17
|
+
components.clear()
|
|
18
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { createRoot } from "react-dom/client"
|
|
3
|
+
import { resolveComponent } from "./registry"
|
|
4
|
+
|
|
5
|
+
const roots = new Map()
|
|
6
|
+
const selector = "[data-react-component]"
|
|
7
|
+
let started = false
|
|
8
|
+
|
|
9
|
+
function elementsWithin(root) {
|
|
10
|
+
const elements = [...root.querySelectorAll(selector)]
|
|
11
|
+
if (typeof root.matches === "function" && root.matches(selector)) elements.unshift(root)
|
|
12
|
+
return elements
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function mountOnTurboLoad() {
|
|
16
|
+
mountAll()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function unmountBeforeTurboCache() {
|
|
20
|
+
unmountAll()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function propsFor(element) {
|
|
24
|
+
const raw = element.dataset.reactProps || "{}"
|
|
25
|
+
return JSON.parse(raw)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function mountElement(element) {
|
|
29
|
+
if (roots.has(element)) return roots.get(element)
|
|
30
|
+
|
|
31
|
+
const Component = resolveComponent(element.dataset.reactComponent)
|
|
32
|
+
if (!Component) throw new Error(`Unknown React component: ${element.dataset.reactComponent}`)
|
|
33
|
+
|
|
34
|
+
const root = createRoot(element)
|
|
35
|
+
root.render(React.createElement(Component, propsFor(element)))
|
|
36
|
+
roots.set(element, root)
|
|
37
|
+
return root
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function mountAll(root = document) {
|
|
41
|
+
elementsWithin(root).forEach(mountElement)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function unmountElement(element) {
|
|
45
|
+
const root = roots.get(element)
|
|
46
|
+
if (!root) return
|
|
47
|
+
root.unmount()
|
|
48
|
+
roots.delete(element)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function unmountAll(root = document) {
|
|
52
|
+
for (const element of roots.keys()) {
|
|
53
|
+
if (root === document || element === root || root.contains(element)) unmountElement(element)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function start() {
|
|
58
|
+
if (started) return
|
|
59
|
+
|
|
60
|
+
mountAll()
|
|
61
|
+
document.addEventListener("turbo:load", mountOnTurboLoad)
|
|
62
|
+
document.addEventListener("turbo:before-cache", unmountBeforeTurboCache)
|
|
63
|
+
started = true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function stop() {
|
|
67
|
+
if (!started) return
|
|
68
|
+
|
|
69
|
+
unmountAll()
|
|
70
|
+
document.removeEventListener("turbo:load", mountOnTurboLoad)
|
|
71
|
+
document.removeEventListener("turbo:before-cache", unmountBeforeTurboCache)
|
|
72
|
+
started = false
|
|
73
|
+
}
|
data/docs/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!-- docs/README.md -->
|
|
2
|
+
|
|
3
|
+
# Documentation
|
|
4
|
+
|
|
5
|
+
- [Project README](../README.md) — installation, compatibility, public APIs, supported asset integration, security, development, and troubleshooting.
|
|
6
|
+
- [Release process](releasing.md) — maintainer validation, RubyGems Trusted Publishing, failure handling, and post-release verification.
|
|
7
|
+
- [Release policy](../RELEASES.md) — ordinary pre-1.0 versioning and the path to `1.0.0.rcN` and `1.0.0`.
|
|
8
|
+
- [Changelog](../CHANGELOG.md) — release notes for shipped and upcoming versions.
|
|
9
|
+
- [MIT License](../LICENSE.txt) — terms for using and distributing ActiveAdmin React.
|
|
10
|
+
|
|
11
|
+
Source, issues, and pull requests are available in the [GitHub repository](https://github.com/scarver2/activeadmin-react).
|
|
12
|
+
|
|
13
|
+
—
|
|
14
|
+
Stan Carver II
|
|
15
|
+
Made in Texas 🤠
|
|
16
|
+
https://stancarver.com
|
data/docs/releasing.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!-- docs/releasing.md -->
|
|
2
|
+
|
|
3
|
+
# Releasing ActiveAdmin React
|
|
4
|
+
|
|
5
|
+
ActiveAdmin React uses reviewed tags and RubyGems Trusted Publishing. Local commands and continuous integration validate a release candidate; only the protected GitHub Actions release job publishes it. Never add a long-lived `RUBYGEMS_API_KEY`, `GEM_HOST_API_KEY`, or RubyGems credentials file to the repository or GitHub secrets.
|
|
6
|
+
|
|
7
|
+
## One-time trusted publisher setup
|
|
8
|
+
|
|
9
|
+
Configure the `activeadmin-react` trusted publisher on RubyGems.org with these exact claims:
|
|
10
|
+
|
|
11
|
+
- GitHub owner: `scarver2`
|
|
12
|
+
- Repository: `activeadmin-react`
|
|
13
|
+
- Workflow filename: `release.yml`
|
|
14
|
+
- Environment: `release`
|
|
15
|
+
|
|
16
|
+
Create the matching GitHub `release` environment and restrict deployment to authorized maintainers and release refs. Require reviewer approval when more than one person can initiate a release. The publish job uses GitHub OIDC to exchange its identity for a short-lived credential scoped by these claims.
|
|
17
|
+
|
|
18
|
+
## Validation does not publish
|
|
19
|
+
|
|
20
|
+
Prepare a release only after the complete pull-request stack has merged into `master`. Update `ActiveAdmin::React::VERSION` and `CHANGELOG.md` together, then verify a clean local checkout of the exact `origin/master` commit:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bin/setup
|
|
24
|
+
bin/test
|
|
25
|
+
bin/package
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`bin/test` runs the Ruby specs, RuboCop, RBS validation, JavaScript tests, and dummy-host integration coverage. `bin/package` builds the gem, inspects the packaged file list, installs it with dependencies in an isolated gem home, and proves Rails loads that installed copy. Neither command tags a commit, contacts RubyGems publishing APIs, or publishes a gem.
|
|
29
|
+
|
|
30
|
+
Before tagging, confirm every required GitHub Actions quality and package job is green for the exact `master` commit. Inspect the generated gem contents and release notes; do not treat a successful local build as approval to publish.
|
|
31
|
+
|
|
32
|
+
## Publishing
|
|
33
|
+
|
|
34
|
+
Create a tag that exactly matches the gem version with a leading `v`: `v0.MINOR.PATCH`, or an authorized prerelease `v0.MINOR.PATCH.alphaN` or `v0.MINOR.PATCH.betaN`. The first integrated prerelease is `v0.1.0.alpha1`. When Rodeo dogfooding indicates readiness to converge on 1.0, use `v1.0.0.rcN`. All numeric components have no leading zeroes, and prerelease `N` starts at 1. Other suffixes, `0.x` release candidates, `1.0.0` alpha/beta versions, and build metadata are rejected by the guard.
|
|
35
|
+
|
|
36
|
+
Push the tag only from the reviewed `master` commit. The tag-triggered `.github/workflows/release.yml` job checks out that commit and publishes through the protected `release` environment and RubyGems Trusted Publishing. Approving that environment deployment authorizes publication; local validation never does.
|
|
37
|
+
|
|
38
|
+
## Release checklist
|
|
39
|
+
|
|
40
|
+
1. Merge the complete reviewed stack into `master` from the bottom up.
|
|
41
|
+
2. Update the version and changelog in a reviewed pull request.
|
|
42
|
+
3. Confirm the exact resulting `master` commit passes every CI quality and package gate.
|
|
43
|
+
4. Run `bin/test` and `bin/package` from a clean checkout of that commit.
|
|
44
|
+
5. Create the matching allowed release tag at that exact commit and push it once.
|
|
45
|
+
6. Review and approve the protected `release` environment deployment.
|
|
46
|
+
7. Verify the version, dependencies, checksum, and provenance on RubyGems.org.
|
|
47
|
+
8. Install the published gem in a clean host and require `active_admin/react`.
|
|
48
|
+
9. Create or complete the matching GitHub Release from the reviewed changelog entry.
|
|
49
|
+
|
|
50
|
+
## Failure and recovery
|
|
51
|
+
|
|
52
|
+
Stop when validation, tag identity, environment approval, or OIDC claim verification fails. Do not bypass a failed gate, move a published tag, overwrite a released version, or fall back to a long-lived RubyGems token. Correct the source through another reviewed pull request, increment the version, and publish a new tag.
|
|
53
|
+
|
|
54
|
+
RubyGems versions are immutable. Yank a version only when its artifact is unsafe or fundamentally unusable, then document the reason in both the changelog and GitHub Release. For ordinary defects, leave the prior release available and publish a corrected PATCH version.
|
|
55
|
+
|
|
56
|
+
—
|
|
57
|
+
Stan Carver II
|
|
58
|
+
Made in Texas 🤠
|
|
59
|
+
https://stancarver.com
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module React
|
|
5
|
+
# Adds the Arbre DSL entry point for rendering a React island.
|
|
6
|
+
module Arbre
|
|
7
|
+
def react_component(component, props: {}, fallback: nil, **html)
|
|
8
|
+
mount = ActiveAdmin::React::Mount.new(component, props: props, fallback: fallback, html: html)
|
|
9
|
+
attributes = mount.attributes
|
|
10
|
+
if fallback.respond_to?(:call)
|
|
11
|
+
attributes['aria-live'] = 'polite' unless attributes.key?('aria-live') || attributes.key?(:'aria-live')
|
|
12
|
+
attributes['role'] = 'status' unless attributes.key?('role') || attributes.key?(:role)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
div(**attributes) do
|
|
16
|
+
text_node fallback.call if fallback.respond_to?(:call)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# lib/active_admin/react/contributions.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveAdmin
|
|
5
|
+
module React
|
|
6
|
+
# Coordinates engine-owned React component contributions.
|
|
7
|
+
module Contributions
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def registry
|
|
11
|
+
@registry ||= Registry.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def register(name, **attributes)
|
|
15
|
+
registry.register(name, **attributes)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def diagnostics
|
|
19
|
+
registry.diagnostics
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reset!
|
|
23
|
+
@registry = Registry.new
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
module ActiveAdmin
|
|
7
|
+
module React
|
|
8
|
+
# Builds immutable HTML attributes for a React island and retains its fallback.
|
|
9
|
+
class Mount
|
|
10
|
+
DATA_COMPONENT = 'react-component'
|
|
11
|
+
DATA_PROPS = 'react-props'
|
|
12
|
+
COMPONENT_PATTERN = /\A[A-Za-z][A-Za-z0-9._-]*\z/
|
|
13
|
+
|
|
14
|
+
def initialize(component, props: {}, fallback: nil, html: {})
|
|
15
|
+
@component = normalize_component(component)
|
|
16
|
+
@props = normalize_props(props)
|
|
17
|
+
@fallback = fallback
|
|
18
|
+
@html = html.dup
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def attributes
|
|
22
|
+
data = html_data.merge(
|
|
23
|
+
DATA_COMPONENT => @component,
|
|
24
|
+
DATA_PROPS => props_json
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@html.merge(data: data)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :fallback
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def normalize_component(component)
|
|
35
|
+
return component.to_s if component.is_a?(String) && component.match?(COMPONENT_PATTERN)
|
|
36
|
+
return component.to_s if component.is_a?(Symbol) && component.to_s.match?(COMPONENT_PATTERN)
|
|
37
|
+
|
|
38
|
+
raise ArgumentError, 'component must be a non-empty Ruby identifier'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def html_data
|
|
42
|
+
data = @html.fetch(:data, {})
|
|
43
|
+
raise ArgumentError, 'html data must be a Hash' unless data.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
data.dup
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def props_json
|
|
49
|
+
@props_json ||= JSON.generate(@props)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Keep the accepted prop grammar explicit so unsupported objects fail closed.
|
|
53
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
54
|
+
def normalize_props(value)
|
|
55
|
+
case value
|
|
56
|
+
when NilClass, TrueClass, FalseClass, String, Integer, Symbol
|
|
57
|
+
value.is_a?(Symbol) ? value.to_s : value
|
|
58
|
+
when Float
|
|
59
|
+
raise ArgumentError, 'props cannot contain a non-finite Float' unless value.finite?
|
|
60
|
+
|
|
61
|
+
value
|
|
62
|
+
when DateTime, Time
|
|
63
|
+
value.iso8601(6)
|
|
64
|
+
when Date
|
|
65
|
+
value.iso8601
|
|
66
|
+
when Array
|
|
67
|
+
value.map { |item| normalize_props(item) }
|
|
68
|
+
when Hash
|
|
69
|
+
value.to_h do |key, item|
|
|
70
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
71
|
+
raise ArgumentError, 'props Hash keys must be Strings or Symbols'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
[key.to_s, normalize_props(item)]
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
raise ArgumentError, "unsupported prop type: #{value.class}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# lib/active_admin/react/registry.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveAdmin
|
|
5
|
+
module React
|
|
6
|
+
# Stores namespaced component contributions with copied, immutable metadata.
|
|
7
|
+
class Registry
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
Entry = Data.define(:name, :namespace, :source, :owner, :surfaces, :metadata) do
|
|
11
|
+
def provenance
|
|
12
|
+
"#{owner.inspect} in namespace #{namespace.inspect} from #{source.inspect}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@entries = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def register(name, **attributes)
|
|
21
|
+
entry = build_entry(name, attributes)
|
|
22
|
+
raise_conflict(entry) if @entries.key?(entry.name)
|
|
23
|
+
|
|
24
|
+
@entries[entry.name] = entry
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fetch(name)
|
|
28
|
+
@entries.fetch(name.to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def each(&)
|
|
32
|
+
ordered_entries.each(&)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_a
|
|
36
|
+
ordered_entries
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def registered?(name)
|
|
40
|
+
@entries.key?(name.to_s.strip)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def diagnostics
|
|
44
|
+
ordered_entries.map do |entry|
|
|
45
|
+
{
|
|
46
|
+
name: entry.name,
|
|
47
|
+
namespace: entry.namespace,
|
|
48
|
+
owner: entry.owner,
|
|
49
|
+
source: entry.source,
|
|
50
|
+
surfaces: entry.surfaces,
|
|
51
|
+
metadata: entry.metadata
|
|
52
|
+
}.freeze
|
|
53
|
+
end.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def build_entry(name, attributes)
|
|
59
|
+
surfaces = attributes.delete(:surfaces) { [:component] }
|
|
60
|
+
Entry.new(
|
|
61
|
+
name: normalize_required(name, :name),
|
|
62
|
+
namespace: normalize_attribute(attributes, :namespace),
|
|
63
|
+
source: normalize_attribute(attributes, :source),
|
|
64
|
+
owner: normalize_attribute(attributes, :owner),
|
|
65
|
+
surfaces: normalize_surfaces(surfaces),
|
|
66
|
+
metadata: immutable_metadata(attributes)
|
|
67
|
+
).freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def immutable_metadata(value, ancestors = Set.new)
|
|
71
|
+
return value.dup.freeze if value.is_a?(String)
|
|
72
|
+
return value unless metadata_container?(value)
|
|
73
|
+
|
|
74
|
+
object_id = value.object_id
|
|
75
|
+
raise ActiveAdmin::React::Error, 'metadata must not contain cyclic containers' if ancestors.include?(object_id)
|
|
76
|
+
|
|
77
|
+
nested_ancestors = ancestors.dup.add(object_id)
|
|
78
|
+
return immutable_hash(value, nested_ancestors) if value.is_a?(Hash)
|
|
79
|
+
return immutable_array(value, nested_ancestors) if value.is_a?(Array)
|
|
80
|
+
|
|
81
|
+
immutable_set(value, nested_ancestors)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def immutable_hash(value, ancestors)
|
|
85
|
+
value.each_with_object({}) do |(key, nested_value), copy|
|
|
86
|
+
copy[immutable_metadata(key, ancestors)] = immutable_metadata(nested_value, ancestors)
|
|
87
|
+
end.freeze
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def immutable_array(value, ancestors)
|
|
91
|
+
value.map { |nested_value| immutable_metadata(nested_value, ancestors) }.freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def immutable_set(value, ancestors)
|
|
95
|
+
value.each_with_object(Set.new) do |nested_value, copy|
|
|
96
|
+
copy << immutable_metadata(nested_value, ancestors)
|
|
97
|
+
end.freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def metadata_container?(value)
|
|
101
|
+
value.is_a?(Hash) || value.is_a?(Array) || value.is_a?(Set)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_attribute(attributes, field)
|
|
105
|
+
normalize_required(attributes.delete(field), field)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_required(value, field)
|
|
109
|
+
normalized = value.to_s.strip
|
|
110
|
+
raise ActiveAdmin::React::Error, "#{field} must be present" if normalized.empty?
|
|
111
|
+
|
|
112
|
+
normalized
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def normalize_surfaces(surfaces)
|
|
116
|
+
normalized = Array(surfaces).map { |surface| normalize_required(surface, :surface).to_sym }.uniq.sort.freeze
|
|
117
|
+
raise ActiveAdmin::React::Error, 'surfaces must not be empty' if normalized.empty?
|
|
118
|
+
|
|
119
|
+
normalized
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def ordered_entries
|
|
123
|
+
@entries.values.sort_by { |entry| [entry.namespace, entry.name, entry.owner] }.freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def raise_conflict(incoming)
|
|
127
|
+
existing = @entries.fetch(incoming.name)
|
|
128
|
+
message = "component #{incoming.name.inspect} is owned by #{existing.provenance}; " \
|
|
129
|
+
"attempted owner #{incoming.provenance}"
|
|
130
|
+
raise ActiveAdmin::React::Error, message
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_admin'
|
|
4
|
+
require_relative 'react/arbre'
|
|
5
|
+
require_relative 'react/contributions'
|
|
6
|
+
require_relative 'react/mount'
|
|
7
|
+
require_relative 'react/registry'
|
|
8
|
+
require_relative 'react/version'
|
|
9
|
+
|
|
10
|
+
module ActiveAdmin
|
|
11
|
+
module React
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Arbre::Element.include(ActiveAdmin::React::Arbre)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# sig/active_admin/react.rbs
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module React
|
|
5
|
+
class Registry
|
|
6
|
+
class Entry
|
|
7
|
+
attr_reader name: String
|
|
8
|
+
attr_reader namespace: String
|
|
9
|
+
attr_reader source: String
|
|
10
|
+
attr_reader owner: String
|
|
11
|
+
attr_reader surfaces: Array[Symbol]
|
|
12
|
+
attr_reader metadata: Hash[Symbol, untyped]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize: () -> void
|
|
16
|
+
def register: (String | Symbol name, namespace: String | Symbol, source: String | Symbol, owner: untyped, ?surfaces: Array[String | Symbol], **untyped metadata) -> Entry
|
|
17
|
+
def fetch: (String | Symbol name) -> Entry
|
|
18
|
+
def each: () { (Entry) -> void } -> Array[Entry]
|
|
19
|
+
| () -> Enumerator[Entry, Array[Entry]]
|
|
20
|
+
def to_a: () -> Array[Entry]
|
|
21
|
+
def registered?: (String | Symbol name) -> bool
|
|
22
|
+
def diagnostics: () -> Array[Hash[Symbol, untyped]]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module Contributions
|
|
26
|
+
def self.registry: () -> Registry
|
|
27
|
+
def self.register: (String | Symbol name, namespace: String | Symbol, source: String | Symbol, owner: untyped, ?surfaces: Array[String | Symbol], **untyped metadata) -> Registry::Entry
|
|
28
|
+
def self.diagnostics: () -> Array[Hash[Symbol, untyped]]
|
|
29
|
+
def self.reset!: () -> Registry
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activeadmin-react
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0.alpha1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Stan Carver II
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activeadmin
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 4.0.0.beta22
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '5'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 4.0.0.beta22
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '5'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rails
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '8.0'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '8.0'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9'
|
|
52
|
+
description: 'An Arbre-native bridge for mounting optional React components inside
|
|
53
|
+
ActiveAdmin while keeping ActiveAdmin Rails-first and server-rendered.
|
|
54
|
+
|
|
55
|
+
'
|
|
56
|
+
executables: []
|
|
57
|
+
extensions: []
|
|
58
|
+
extra_rdoc_files: []
|
|
59
|
+
files:
|
|
60
|
+
- CHANGELOG.md
|
|
61
|
+
- LICENSE.txt
|
|
62
|
+
- README.md
|
|
63
|
+
- RELEASES.md
|
|
64
|
+
- app/javascript/active_admin/react/cable.js
|
|
65
|
+
- app/javascript/active_admin/react/index.js
|
|
66
|
+
- app/javascript/active_admin/react/operation.js
|
|
67
|
+
- app/javascript/active_admin/react/protocol.js
|
|
68
|
+
- app/javascript/active_admin/react/registry.js
|
|
69
|
+
- app/javascript/active_admin/react/runtime.js
|
|
70
|
+
- docs/README.md
|
|
71
|
+
- docs/releasing.md
|
|
72
|
+
- lib/active_admin/react.rb
|
|
73
|
+
- lib/active_admin/react/arbre.rb
|
|
74
|
+
- lib/active_admin/react/contributions.rb
|
|
75
|
+
- lib/active_admin/react/mount.rb
|
|
76
|
+
- lib/active_admin/react/registry.rb
|
|
77
|
+
- lib/active_admin/react/version.rb
|
|
78
|
+
- sig/active_admin/react.rbs
|
|
79
|
+
homepage: https://github.com/scarver2/activeadmin-react
|
|
80
|
+
licenses:
|
|
81
|
+
- MIT
|
|
82
|
+
metadata:
|
|
83
|
+
bug_tracker_uri: https://github.com/scarver2/activeadmin-react/issues
|
|
84
|
+
changelog_uri: https://github.com/scarver2/activeadmin-react/blob/v0.1.0.alpha1/CHANGELOG.md
|
|
85
|
+
documentation_uri: https://github.com/scarver2/activeadmin-react/blob/v0.1.0.alpha1/README.md
|
|
86
|
+
homepage_uri: https://github.com/scarver2/activeadmin-react
|
|
87
|
+
rubygems_mfa_required: 'true'
|
|
88
|
+
source_code_uri: https://github.com/scarver2/activeadmin-react/tree/v0.1.0.alpha1
|
|
89
|
+
rdoc_options: []
|
|
90
|
+
require_paths:
|
|
91
|
+
- lib
|
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.2'
|
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '0'
|
|
102
|
+
requirements: []
|
|
103
|
+
rubygems_version: 4.0.16
|
|
104
|
+
specification_version: 4
|
|
105
|
+
summary: React islands for ActiveAdmin
|
|
106
|
+
test_files: []
|