onboard_on_rails 0.1.0
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/README.md +235 -0
- data/Rakefile +10 -0
- data/app/assets/javascripts/onboard_on_rails/admin.js +306 -0
- data/app/assets/javascripts/onboard_on_rails/client.js +622 -0
- data/app/assets/stylesheets/onboard_on_rails/admin.css +963 -0
- data/app/assets/stylesheets/onboard_on_rails/client.css +228 -0
- data/app/controllers/onboard_on_rails/admin/base_controller.rb +30 -0
- data/app/controllers/onboard_on_rails/admin/lessons_controller.rb +45 -0
- data/app/controllers/onboard_on_rails/admin/stats_controller.rb +29 -0
- data/app/controllers/onboard_on_rails/admin/steps_controller.rb +57 -0
- data/app/controllers/onboard_on_rails/admin/tours_controller.rb +88 -0
- data/app/controllers/onboard_on_rails/api/base_controller.rb +26 -0
- data/app/controllers/onboard_on_rails/api/completions_controller.rb +30 -0
- data/app/controllers/onboard_on_rails/api/events_controller.rb +19 -0
- data/app/controllers/onboard_on_rails/api/tours_controller.rb +58 -0
- data/app/controllers/onboard_on_rails/application_controller.rb +5 -0
- data/app/controllers/onboard_on_rails/selector_picker_controller.rb +25 -0
- data/app/helpers/onboard_on_rails/meta_tags_helper.rb +25 -0
- data/app/models/onboard_on_rails/application_record.rb +5 -0
- data/app/models/onboard_on_rails/completion.rb +18 -0
- data/app/models/onboard_on_rails/concerns/segment_evaluator.rb +64 -0
- data/app/models/onboard_on_rails/concerns/url_matchable.rb +49 -0
- data/app/models/onboard_on_rails/event.rb +11 -0
- data/app/models/onboard_on_rails/step.rb +21 -0
- data/app/models/onboard_on_rails/tour.rb +33 -0
- data/app/services/onboard_on_rails/ab_assigner.rb +21 -0
- data/app/services/onboard_on_rails/completions_csv_exporter.rb +81 -0
- data/app/services/onboard_on_rails/self_tour_seeder.rb +278 -0
- data/app/services/onboard_on_rails/stats_calculator.rb +39 -0
- data/app/services/onboard_on_rails/tour_copier.rb +31 -0
- data/app/services/onboard_on_rails/tour_matcher.rb +117 -0
- data/app/views/layouts/onboard_on_rails/admin.html.erb +43 -0
- data/app/views/onboard_on_rails/admin/lessons/index.html.erb +45 -0
- data/app/views/onboard_on_rails/admin/stats/show.html.erb +190 -0
- data/app/views/onboard_on_rails/admin/steps/_form.html.erb +155 -0
- data/app/views/onboard_on_rails/admin/steps/_preview.html.erb +58 -0
- data/app/views/onboard_on_rails/admin/steps/edit.html.erb +18 -0
- data/app/views/onboard_on_rails/admin/steps/new.html.erb +10 -0
- data/app/views/onboard_on_rails/admin/tours/_form.html.erb +146 -0
- data/app/views/onboard_on_rails/admin/tours/_tour.html.erb +20 -0
- data/app/views/onboard_on_rails/admin/tours/edit.html.erb +48 -0
- data/app/views/onboard_on_rails/admin/tours/index.html.erb +40 -0
- data/app/views/onboard_on_rails/admin/tours/new.html.erb +10 -0
- data/app/views/onboard_on_rails/selector_picker/show.html.erb +62 -0
- data/config/locales/en.yml +265 -0
- data/config/locales/ru.yml +328 -0
- data/config/routes.rb +29 -0
- data/db/migrate/20260403000001_create_onboard_on_rails_tours.rb +25 -0
- data/db/migrate/20260403000002_create_onboard_on_rails_steps.rb +20 -0
- data/db/migrate/20260403000003_create_onboard_on_rails_completions.rb +19 -0
- data/db/migrate/20260403000004_create_onboard_on_rails_events.rb +13 -0
- data/db/migrate/20260404000001_add_matched_urls_to_onboard_on_rails_completions.rb +5 -0
- data/db/migrate/20260404000002_add_complete_on_target_click_to_onboard_on_rails_steps.rb +5 -0
- data/db/migrate/20260404000003_add_device_type_to_onboard_on_rails_tours.rb +5 -0
- data/db/migrate/20260414000001_add_overlay_enabled_to_onboard_on_rails_tours.rb +5 -0
- data/lib/onboard_on_rails/attribute_definition.rb +3 -0
- data/lib/onboard_on_rails/configuration.rb +79 -0
- data/lib/onboard_on_rails/engine.rb +29 -0
- data/lib/onboard_on_rails/version.rb +3 -0
- data/lib/onboard_on_rails.rb +24 -0
- metadata +171 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6db171d145c948dd206a246da8de2c21c4e9abfb9f7d7f078a090c47009df1a7
|
|
4
|
+
data.tar.gz: 3192fe22e98c26a5792e7cd13e41e9f526fbb39528ca8bae907b8dc472d5c286
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 57245caf7f6477dd5b290260410beca6d57aff53149acee4c1b3c710811b871520c295a4d5cde5eac388c0ea1b4101ae98550c7e265d309a094f81121291f60a
|
|
7
|
+
data.tar.gz: 86748d9f85f060cdd897be576acdb0561dd08afec36fe3e5e67d1b5d97490757f89b7bca4720d574660b21decb898007f7aa37cac5bd9890a4c798bf0a8a6f68
|
data/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# OnboardOnRails
|
|
2
|
+
|
|
3
|
+
A universal onboarding tour engine for Ruby on Rails. Mount a full-featured admin panel into your app and create interactive product tours — no front-end framework required.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Admin Panel** — create and manage tours, steps, and lessons from a browser UI
|
|
8
|
+
- **Visual Selector Picker** — point-and-click CSS selector builder (iframe-based)
|
|
9
|
+
- **4 Themes** — Tooltip, Modal, Banner, Slideout — configurable per tour and per step
|
|
10
|
+
- **A/B Testing** — split users into deterministic groups, compare completion rates
|
|
11
|
+
- **User Targeting** — DSL for registering targetable attributes with 15 operators (eq, starts_with, contains, matches, in, gt, etc.)
|
|
12
|
+
- **Scheduling** — set start/end dates for time-limited tours
|
|
13
|
+
- **Frequency Control** — once, every session, or always
|
|
14
|
+
- **Trigger Types** — auto (page load), event-based, or manual via API
|
|
15
|
+
- **Device Targeting** — run tours on all devices, desktop only, or mobile only
|
|
16
|
+
- **Theming** — configurable accent color and default font applied to admin panel and tour defaults
|
|
17
|
+
- **Overlay Toggle** — per-tour backdrop on/off
|
|
18
|
+
- **SSR + SPA support** — vanilla JS client works with Turbo, React, or classic Rails
|
|
19
|
+
- **i18n** — English and Russian out of the box, with per-user locale resolution
|
|
20
|
+
- **Self-Tour Lessons** — built-in interactive tutorials that teach the admin panel itself
|
|
21
|
+
- **Statistics** — completion rates, drop-off per step, A/B breakdown
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Ruby >= 3.1
|
|
26
|
+
- Rails >= 7.0
|
|
27
|
+
- PostgreSQL (jsonb columns for url_pattern, segment_rules, style_overrides)
|
|
28
|
+
|
|
29
|
+
## Quick Installation
|
|
30
|
+
|
|
31
|
+
### 1. Add the gem
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# Gemfile
|
|
35
|
+
gem "onboard_on_rails"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bundle install
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 2. Mount the engine
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# config/routes.rb
|
|
46
|
+
Rails.application.routes.draw do
|
|
47
|
+
mount OnboardOnRails::Engine => "/onboard"
|
|
48
|
+
# ...
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 3. Run migrations
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
bin/rails onboard_on_rails:install:migrations
|
|
56
|
+
bin/rails db:migrate
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 4. Create initializer
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# config/initializers/onboard_on_rails.rb
|
|
63
|
+
OnboardOnRails.configure do |config|
|
|
64
|
+
config.user_class = "User"
|
|
65
|
+
config.current_user_method = :current_user
|
|
66
|
+
|
|
67
|
+
config.admin_auth = ->(controller) {
|
|
68
|
+
controller.current_user&.admin?
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Optional: brand the admin panel and tour defaults
|
|
72
|
+
config.accent_color = "#2d3436" # hex, 6 digits — dark/light/rgba variants derived automatically
|
|
73
|
+
config.default_font = "Inter, sans-serif" # applied to tours when no style override is set
|
|
74
|
+
|
|
75
|
+
# Optional: resolve the locale for each user (defaults to "ru")
|
|
76
|
+
config.user_locale = ->(user) { user.locale || "en" }
|
|
77
|
+
|
|
78
|
+
config.register_attribute :email, type: :string, label: "Email" do |user|
|
|
79
|
+
user.email
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
config.register_attribute :plan, type: :string, label: "Plan", values: ["free", "pro", "enterprise"] do |user|
|
|
83
|
+
user.plan
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 5. Add meta tags and assets to your layout
|
|
89
|
+
|
|
90
|
+
```erb
|
|
91
|
+
<%# app/views/layouts/application.html.erb %>
|
|
92
|
+
<head>
|
|
93
|
+
<%= onboard_on_rails_meta_tags %>
|
|
94
|
+
<%= javascript_include_tag "onboard_on_rails/client" %>
|
|
95
|
+
<%= stylesheet_link_tag "onboard_on_rails/client" %>
|
|
96
|
+
</head>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
| Option | Type | Default | Description |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| `user_class` | String | `"User"` | ActiveRecord model representing users |
|
|
104
|
+
| `admin_auth` | Lambda | `->(_) { true }` | Receives controller, returns true/false for admin access |
|
|
105
|
+
| `current_user_method` | Symbol | `:current_user` | Method name on your ApplicationController that returns the current user |
|
|
106
|
+
| `user_locale` | Lambda | `->(_) { "ru" }` | Receives the user, returns a locale code used to render tours |
|
|
107
|
+
| `accent_color` | String | `"#2d3436"` | 6-digit hex color; drives admin panel branding and default tour accents (light/dark/rgba variants are derived automatically) |
|
|
108
|
+
| `default_font` | String | `nil` | CSS `font-family` used as a default on tours when no style override is set |
|
|
109
|
+
| `register_attribute` | DSL | — | Register a user attribute for targeting (see below) |
|
|
110
|
+
|
|
111
|
+
## User Targeting
|
|
112
|
+
|
|
113
|
+
Register attributes that can be used for targeting in the admin panel. Each attribute needs a type, label, and a block that extracts the value from the user object.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
OnboardOnRails.configure do |config|
|
|
117
|
+
# String attributes — supports: eq, not_eq, in, not_in, starts_with, ends_with,
|
|
118
|
+
# contains, not_contains, matches (regex), length_gt, length_lt
|
|
119
|
+
config.register_attribute :email, type: :string, label: "Email",
|
|
120
|
+
description: "User email address" do |user|
|
|
121
|
+
user.email
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# String with predefined values — admin sees a dropdown instead of text input
|
|
125
|
+
config.register_attribute :plan, type: :string, label: "Plan",
|
|
126
|
+
description: "Subscription plan",
|
|
127
|
+
values: ["free", "pro", "enterprise"] do |user|
|
|
128
|
+
user.plan
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Number attributes — supports: eq, not_eq, in, not_in, gt, lt, gte, lte
|
|
132
|
+
config.register_attribute :account_id, type: :number, label: "Account ID",
|
|
133
|
+
description: "ID of the user's account" do |user|
|
|
134
|
+
user.account_id
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Boolean attributes — supports: eq (true/false dropdown)
|
|
138
|
+
config.register_attribute :admin, type: :boolean, label: "Admin?",
|
|
139
|
+
description: "Whether the user is an admin" do |user|
|
|
140
|
+
user.admin?
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Parameters
|
|
146
|
+
|
|
147
|
+
| Parameter | Required | Description |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `key` | yes | Symbol identifier (first argument) |
|
|
150
|
+
| `type` | yes | `:string`, `:number`, or `:boolean` |
|
|
151
|
+
| `label` | yes | Display name in the admin panel |
|
|
152
|
+
| `description` | no | Help text shown below the attribute selector |
|
|
153
|
+
| `values` | no | Array of allowed values (renders as dropdown in admin) |
|
|
154
|
+
| `block` | yes | `\|user\| -> value` — extracts the attribute value |
|
|
155
|
+
|
|
156
|
+
### Examples
|
|
157
|
+
|
|
158
|
+
Show a tour to users whose email starts with "foo":
|
|
159
|
+
- Attribute: `email`, Operator: `starts_with`, Value: `foo`
|
|
160
|
+
|
|
161
|
+
Show a tour to specific accounts:
|
|
162
|
+
- Attribute: `account_id`, Operator: `in`, Value: `123, 456, 789`
|
|
163
|
+
|
|
164
|
+
Show a tour to users with names longer than 10 characters:
|
|
165
|
+
- Attribute: `name`, Operator: `length_gt`, Value: `10`
|
|
166
|
+
|
|
167
|
+
Combine multiple conditions with AND/OR logic in the admin panel.
|
|
168
|
+
|
|
169
|
+
## Usage
|
|
170
|
+
|
|
171
|
+
1. Open the admin panel at `/onboard/admin`
|
|
172
|
+
2. Click **New Tour**, fill in name, URL pattern, theme, and trigger settings
|
|
173
|
+
3. Add steps — set CSS selector, placement, title, and body text
|
|
174
|
+
4. Use the visual selector picker to choose elements on your pages
|
|
175
|
+
5. Set the tour status to **Active**
|
|
176
|
+
6. Visit the target page as a logged-in user — the tour starts automatically
|
|
177
|
+
|
|
178
|
+
## Self-Tour Lessons
|
|
179
|
+
|
|
180
|
+
OnboardOnRails ships with built-in tutorials that teach admins how to use the panel:
|
|
181
|
+
|
|
182
|
+
- **Lesson 1**: Overview of the admin panel
|
|
183
|
+
- **Lesson 2**: Creating and configuring a tour
|
|
184
|
+
- **Lesson 3**: Adding and styling steps
|
|
185
|
+
|
|
186
|
+
To create them, go to `/onboard/admin/lessons` and click **Create Lessons**, or call:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
OnboardOnRails::SelfTourSeeder.seed!
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## API
|
|
193
|
+
|
|
194
|
+
### Client-side (JavaScript)
|
|
195
|
+
|
|
196
|
+
```javascript
|
|
197
|
+
// Track a custom event
|
|
198
|
+
OnboardOnRails.trackEvent("first_project_created", { project_id: 42 });
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Server-side (Ruby)
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
# Track an event for a user
|
|
205
|
+
OnboardOnRails.track_event(user, "subscription_activated", { plan: "pro" })
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### REST Endpoints
|
|
209
|
+
|
|
210
|
+
| Method | Path | Description |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| GET | `/onboard/api/tours?url=/dashboard` | Fetch matching tour for URL |
|
|
213
|
+
| POST | `/onboard/api/completions` | Create/update completion record |
|
|
214
|
+
| POST | `/onboard/api/events` | Track a custom event |
|
|
215
|
+
|
|
216
|
+
## Tech Stack
|
|
217
|
+
|
|
218
|
+
| Layer | Technology |
|
|
219
|
+
|---|---|
|
|
220
|
+
| Backend | Rails Engine (mountable, isolated namespace) |
|
|
221
|
+
| Database | PostgreSQL with jsonb columns |
|
|
222
|
+
| Admin JS | Vanilla JavaScript (no framework) |
|
|
223
|
+
| Client JS | Vanilla JavaScript (no framework) |
|
|
224
|
+
| Asset pipeline | Sprockets |
|
|
225
|
+
| Styling | Plain CSS with custom properties |
|
|
226
|
+
| i18n | Rails I18n (en, ru) |
|
|
227
|
+
| Testing | RSpec + FactoryBot |
|
|
228
|
+
|
|
229
|
+
## Detailed Documentation
|
|
230
|
+
|
|
231
|
+
See [docs/setup.md](docs/setup.md) for comprehensive Russian documentation covering authentication, targeting, theming, A/B testing, API reference, and more.
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
require "bundler/gem_tasks"
|
|
3
|
+
|
|
4
|
+
APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
|
|
5
|
+
load "rails/tasks/engine.rake"
|
|
6
|
+
load "rails/tasks/statistics.rake"
|
|
7
|
+
|
|
8
|
+
require "rspec/core/rake_task"
|
|
9
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
10
|
+
task default: :spec
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// OnboardOnRails Admin Bundle
|
|
2
|
+
// Compatible with both Sprockets and Propshaft
|
|
3
|
+
|
|
4
|
+
// === Step Preview Controller ===
|
|
5
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
6
|
+
// Find the step edit form — only run on step edit pages
|
|
7
|
+
var form = document.querySelector("[data-controller='step-preview']");
|
|
8
|
+
if (!form) return;
|
|
9
|
+
|
|
10
|
+
function updatePreview() {
|
|
11
|
+
var title = document.querySelector("[data-step-preview-target='title']");
|
|
12
|
+
var body = document.querySelector("[data-step-preview-target='body']");
|
|
13
|
+
var previewTitle = document.querySelector("[data-step-preview-target='previewTitle']");
|
|
14
|
+
var previewBody = document.querySelector("[data-step-preview-target='previewBody']");
|
|
15
|
+
var previewButton = document.querySelector("[data-step-preview-target='previewButton']");
|
|
16
|
+
var bgColor = document.querySelector("[data-step-preview-target='bgColor']");
|
|
17
|
+
var textColor = document.querySelector("[data-step-preview-target='textColor']");
|
|
18
|
+
var buttonColor = document.querySelector("[data-step-preview-target='buttonColor']");
|
|
19
|
+
var fontFamily = document.querySelector("[data-step-preview-target='fontFamily']");
|
|
20
|
+
var fontSize = document.querySelector("[data-step-preview-target='fontSize']");
|
|
21
|
+
var borderRadius = document.querySelector("[data-step-preview-target='borderRadius']");
|
|
22
|
+
var tooltipBody = document.querySelector("[data-step-preview-target='tooltipBody']");
|
|
23
|
+
|
|
24
|
+
if (previewTitle && title) previewTitle.textContent = title.value || "Step Title";
|
|
25
|
+
if (previewBody && body) previewBody.textContent = body.value || "Step description goes here...";
|
|
26
|
+
|
|
27
|
+
if (tooltipBody) {
|
|
28
|
+
if (bgColor) tooltipBody.style.background = bgColor.value;
|
|
29
|
+
if (fontFamily) tooltipBody.style.fontFamily = fontFamily.value;
|
|
30
|
+
if (borderRadius) tooltipBody.style.borderRadius = borderRadius.value;
|
|
31
|
+
}
|
|
32
|
+
if (previewTitle && textColor) previewTitle.style.color = textColor.value;
|
|
33
|
+
if (previewBody && textColor) previewBody.style.color = textColor.value;
|
|
34
|
+
if (previewBody && fontSize) previewBody.style.fontSize = fontSize.value;
|
|
35
|
+
if (previewButton && buttonColor) previewButton.style.background = buttonColor.value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Attach listeners to all form inputs
|
|
39
|
+
form.querySelectorAll("input, textarea, select").forEach(function(el) {
|
|
40
|
+
el.addEventListener("input", updatePreview);
|
|
41
|
+
el.addEventListener("change", updatePreview);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// === Segment Rules Controller ===
|
|
46
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
47
|
+
var wrapper = document.querySelector("[data-controller='segment-rules']");
|
|
48
|
+
if (!wrapper) return;
|
|
49
|
+
|
|
50
|
+
var container = wrapper.querySelector("[data-segment-rules-target='container']");
|
|
51
|
+
var output = wrapper.querySelector("[data-segment-rules-target='output']");
|
|
52
|
+
var logicSelect = wrapper.querySelector("[data-segment-rules-target='logic']");
|
|
53
|
+
var addButton = wrapper.querySelector("[data-action*='segment-rules#add']");
|
|
54
|
+
|
|
55
|
+
var availableAttributes = [];
|
|
56
|
+
var operatorLabels = {};
|
|
57
|
+
var placeholders = {};
|
|
58
|
+
|
|
59
|
+
try { availableAttributes = JSON.parse(wrapper.getAttribute("data-available-attributes") || "[]"); } catch(e) {}
|
|
60
|
+
try { operatorLabels = JSON.parse(wrapper.getAttribute("data-operator-labels") || "{}"); } catch(e) {}
|
|
61
|
+
try { placeholders = JSON.parse(wrapper.getAttribute("data-placeholders") || "{}"); } catch(e) {}
|
|
62
|
+
|
|
63
|
+
var OPERATORS_BY_TYPE = {
|
|
64
|
+
string: ["eq", "not_eq", "in", "not_in", "starts_with", "ends_with", "contains", "not_contains", "matches", "length_gt", "length_lt"],
|
|
65
|
+
number: ["eq", "not_eq", "in", "not_in", "gt", "lt", "gte", "lte"],
|
|
66
|
+
boolean: ["eq"]
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function findAttr(key) {
|
|
70
|
+
for (var i = 0; i < availableAttributes.length; i++) {
|
|
71
|
+
if (availableAttributes[i].key === key) return availableAttributes[i];
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function serialize() {
|
|
77
|
+
var rows = container.querySelectorAll(".oor-segment-row");
|
|
78
|
+
var conditions = Array.from(rows).map(function(row) {
|
|
79
|
+
var op = row.querySelector(".oor-segment-op").value;
|
|
80
|
+
var val = row.querySelector(".oor-segment-val").value;
|
|
81
|
+
if (op === "in" || op === "not_in") {
|
|
82
|
+
val = val.split(",").map(function(v) { return v.trim(); });
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
attribute: row.querySelector(".oor-segment-attr").value,
|
|
86
|
+
operator: op,
|
|
87
|
+
value: val
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
var logic = logicSelect ? logicSelect.value : "and";
|
|
91
|
+
output.value = JSON.stringify({ conditions: conditions, logic: logic });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildAttrSelect(selectedKey) {
|
|
95
|
+
var html = "";
|
|
96
|
+
for (var i = 0; i < availableAttributes.length; i++) {
|
|
97
|
+
var a = availableAttributes[i];
|
|
98
|
+
var sel = a.key === selectedKey ? " selected" : "";
|
|
99
|
+
html += '<option value="' + a.key + '"' + sel + '>' + a.label + '</option>';
|
|
100
|
+
}
|
|
101
|
+
return html;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildOpSelect(type, selectedOp) {
|
|
105
|
+
var ops = OPERATORS_BY_TYPE[type] || OPERATORS_BY_TYPE.string;
|
|
106
|
+
var html = "";
|
|
107
|
+
for (var i = 0; i < ops.length; i++) {
|
|
108
|
+
var op = ops[i];
|
|
109
|
+
var label = operatorLabels[op] || op;
|
|
110
|
+
var sel = op === selectedOp ? " selected" : "";
|
|
111
|
+
html += '<option value="' + op + '"' + sel + '>' + label + '</option>';
|
|
112
|
+
}
|
|
113
|
+
return html;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildValueInput(attrDef, operator, value) {
|
|
117
|
+
if (operator === "eq" && attrDef && attrDef.type === "boolean") {
|
|
118
|
+
return '<select class="oor-segment-val oor-form-control" style="flex:1;">' +
|
|
119
|
+
'<option value="true"' + (value === "true" ? " selected" : "") + '>' + (placeholders.boolean_true || "true") + '</option>' +
|
|
120
|
+
'<option value="false"' + (value !== "true" ? " selected" : "") + '>' + (placeholders.boolean_false || "false") + '</option>' +
|
|
121
|
+
'</select>';
|
|
122
|
+
}
|
|
123
|
+
if ((operator === "eq" || operator === "not_eq") && attrDef && attrDef.values && attrDef.values.length > 0) {
|
|
124
|
+
var html = '<select class="oor-segment-val oor-form-control" style="flex:1;">';
|
|
125
|
+
for (var i = 0; i < attrDef.values.length; i++) {
|
|
126
|
+
var v = attrDef.values[i];
|
|
127
|
+
var sel = v === value ? " selected" : "";
|
|
128
|
+
html += '<option value="' + v + '"' + sel + '>' + v + '</option>';
|
|
129
|
+
}
|
|
130
|
+
html += '</select>';
|
|
131
|
+
return html;
|
|
132
|
+
}
|
|
133
|
+
var placeholder = placeholders.value || "value";
|
|
134
|
+
if (operator === "in" || operator === "not_in") placeholder = placeholders.in_values || "values separated by comma";
|
|
135
|
+
if (operator === "matches") placeholder = placeholders.regex || "regular expression";
|
|
136
|
+
var displayValue = Array.isArray(value) ? value.join(", ") : (value || "");
|
|
137
|
+
return '<input type="text" placeholder="' + placeholder + '" value="' + displayValue + '" class="oor-segment-val oor-form-control" style="flex:1;">';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function addConditionRow(condition) {
|
|
141
|
+
var attrKey = condition.attribute || (availableAttributes[0] ? availableAttributes[0].key : "");
|
|
142
|
+
var attrDef = findAttr(attrKey);
|
|
143
|
+
var type = attrDef ? attrDef.type : "string";
|
|
144
|
+
var operator = condition.operator || "eq";
|
|
145
|
+
|
|
146
|
+
var row = document.createElement("div");
|
|
147
|
+
row.className = "oor-segment-row";
|
|
148
|
+
|
|
149
|
+
row.innerHTML =
|
|
150
|
+
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">' +
|
|
151
|
+
'<select class="oor-segment-attr oor-form-control" style="flex:1;min-width:120px;">' + buildAttrSelect(attrKey) + '</select>' +
|
|
152
|
+
'<select class="oor-segment-op oor-form-control" style="flex:1;min-width:120px;">' + buildOpSelect(type, operator) + '</select>' +
|
|
153
|
+
buildValueInput(attrDef, operator, Array.isArray(condition.value) ? condition.value.join(", ") : (condition.value || "")) +
|
|
154
|
+
'<button type="button" class="oor-segment-remove oor-btn oor-btn--sm oor-btn--danger" style="flex-shrink:0;">×</button>' +
|
|
155
|
+
'</div>' +
|
|
156
|
+
(attrDef && attrDef.description ? '<div class="oor-segment-description">' + attrDef.description + '</div>' : '');
|
|
157
|
+
|
|
158
|
+
row.querySelector(".oor-segment-remove").addEventListener("click", function() {
|
|
159
|
+
row.remove();
|
|
160
|
+
serialize();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
var attrSelect = row.querySelector(".oor-segment-attr");
|
|
164
|
+
attrSelect.addEventListener("change", function() {
|
|
165
|
+
var newAttrDef = findAttr(attrSelect.value);
|
|
166
|
+
var newType = newAttrDef ? newAttrDef.type : "string";
|
|
167
|
+
var opSelect = row.querySelector(".oor-segment-op");
|
|
168
|
+
opSelect.innerHTML = buildOpSelect(newType, "eq");
|
|
169
|
+
var oldValEl = row.querySelector(".oor-segment-val");
|
|
170
|
+
var temp = document.createElement("div");
|
|
171
|
+
temp.innerHTML = buildValueInput(newAttrDef, "eq", "");
|
|
172
|
+
oldValEl.parentNode.replaceChild(temp.firstChild, oldValEl);
|
|
173
|
+
var descEl = row.querySelector(".oor-segment-description");
|
|
174
|
+
if (descEl) descEl.remove();
|
|
175
|
+
if (newAttrDef && newAttrDef.description) {
|
|
176
|
+
var newDesc = document.createElement("div");
|
|
177
|
+
newDesc.className = "oor-segment-description";
|
|
178
|
+
newDesc.textContent = newAttrDef.description;
|
|
179
|
+
row.appendChild(newDesc);
|
|
180
|
+
}
|
|
181
|
+
bindRowEvents(row);
|
|
182
|
+
serialize();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
var opSelect = row.querySelector(".oor-segment-op");
|
|
186
|
+
opSelect.addEventListener("change", function() {
|
|
187
|
+
var currentAttrDef = findAttr(attrSelect.value);
|
|
188
|
+
var oldValEl = row.querySelector(".oor-segment-val");
|
|
189
|
+
var temp = document.createElement("div");
|
|
190
|
+
temp.innerHTML = buildValueInput(currentAttrDef, opSelect.value, "");
|
|
191
|
+
oldValEl.parentNode.replaceChild(temp.firstChild, oldValEl);
|
|
192
|
+
bindRowEvents(row);
|
|
193
|
+
serialize();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
bindRowEvents(row);
|
|
197
|
+
container.appendChild(row);
|
|
198
|
+
serialize();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function bindRowEvents(row) {
|
|
202
|
+
row.querySelectorAll(".oor-segment-val, .oor-segment-op, .oor-segment-attr").forEach(function(el) {
|
|
203
|
+
el.removeEventListener("change", serialize);
|
|
204
|
+
el.removeEventListener("input", serialize);
|
|
205
|
+
el.addEventListener("change", serialize);
|
|
206
|
+
el.addEventListener("input", serialize);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function loadExisting() {
|
|
211
|
+
var data;
|
|
212
|
+
try { data = JSON.parse(output.value || "{}"); } catch(e) { data = {}; }
|
|
213
|
+
if (data.conditions && data.conditions.length > 0) {
|
|
214
|
+
data.conditions.forEach(function(c) { addConditionRow(c); });
|
|
215
|
+
}
|
|
216
|
+
if (data.logic && logicSelect) logicSelect.value = data.logic;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (addButton) {
|
|
220
|
+
addButton.addEventListener("click", function(e) {
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
addConditionRow({ attribute: "", operator: "eq", value: "" });
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (logicSelect) {
|
|
227
|
+
logicSelect.addEventListener("change", serialize);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
loadExisting();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// === Sortable Controller ===
|
|
234
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
235
|
+
var wrapper = document.querySelector("[data-controller='sortable']");
|
|
236
|
+
if (!wrapper) return;
|
|
237
|
+
|
|
238
|
+
var sortableUrl = wrapper.getAttribute("data-sortable-url-value");
|
|
239
|
+
var draggedItem = null;
|
|
240
|
+
|
|
241
|
+
wrapper.querySelectorAll("[data-step-id]").forEach(function(item) {
|
|
242
|
+
item.draggable = true;
|
|
243
|
+
|
|
244
|
+
item.addEventListener("dragstart", function(e) {
|
|
245
|
+
draggedItem = e.currentTarget;
|
|
246
|
+
e.currentTarget.style.opacity = "0.4";
|
|
247
|
+
e.dataTransfer.effectAllowed = "move";
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
item.addEventListener("dragover", function(e) {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
e.dataTransfer.dropEffect = "move";
|
|
253
|
+
var target = e.currentTarget;
|
|
254
|
+
if (target !== draggedItem) {
|
|
255
|
+
var rect = target.getBoundingClientRect();
|
|
256
|
+
if (e.clientY < rect.top + rect.height / 2) {
|
|
257
|
+
target.parentNode.insertBefore(draggedItem, target);
|
|
258
|
+
} else {
|
|
259
|
+
target.parentNode.insertBefore(draggedItem, target.nextSibling);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
item.addEventListener("drop", function(e) {
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
saveOrder();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
item.addEventListener("dragend", function(e) {
|
|
270
|
+
e.currentTarget.style.opacity = "1";
|
|
271
|
+
draggedItem = null;
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
function saveOrder() {
|
|
276
|
+
var items = wrapper.querySelectorAll("[data-step-id]");
|
|
277
|
+
var order = Array.from(items).map(function(item, i) {
|
|
278
|
+
return { id: item.dataset.stepId, position: i + 1 };
|
|
279
|
+
});
|
|
280
|
+
if (sortableUrl) {
|
|
281
|
+
var csrfToken = document.querySelector('meta[name="csrf-token"]');
|
|
282
|
+
fetch(sortableUrl, {
|
|
283
|
+
method: "PATCH",
|
|
284
|
+
headers: {
|
|
285
|
+
"Content-Type": "application/json",
|
|
286
|
+
"X-CSRF-Token": csrfToken ? csrfToken.content : ""
|
|
287
|
+
},
|
|
288
|
+
body: JSON.stringify({ order: order })
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// === Selector Picker Controller ===
|
|
295
|
+
// Register on window directly (not inside DOMContentLoaded) so it survives Turbo Drive navigations
|
|
296
|
+
window.addEventListener("message", function(event) {
|
|
297
|
+
if (event.data && event.data.type === "oor-selector-picked") {
|
|
298
|
+
var input = document.querySelector("input[name='step[selector]']")
|
|
299
|
+
|| document.getElementById("step_selector");
|
|
300
|
+
if (input) {
|
|
301
|
+
input.value = event.data.selector;
|
|
302
|
+
input.dispatchEvent(new Event("input"));
|
|
303
|
+
input.dispatchEvent(new Event("change"));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|